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:
@@ -2,7 +2,7 @@ use std::sync::Arc;
|
||||
|
||||
use domain::activity::ActivityId;
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::{ActivityCommandPort, ActivityQueryPort};
|
||||
use domain::ports::{ActivityCommandPort, ActivityQueryPort, MoodEntryQueryPort};
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::authorization::verify_ownership;
|
||||
@@ -11,6 +11,7 @@ use crate::errors::ApplicationError;
|
||||
pub struct Deps {
|
||||
pub command: Arc<dyn ActivityCommandPort>,
|
||||
pub query: Arc<dyn ActivityQueryPort>,
|
||||
pub entries: Arc<dyn MoodEntryQueryPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
@@ -26,8 +27,24 @@ pub async fn execute(
|
||||
.ok_or_else(|| DomainError::NotFound("activity not found".into()))?;
|
||||
|
||||
verify_ownership(activity.user_id(), &caller_id)?;
|
||||
refuse_while_entries_still_wear_it(&activity_id, deps).await?;
|
||||
|
||||
deps.command.delete(&activity_id).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn refuse_while_entries_still_wear_it(
|
||||
activity_id: &ActivityId,
|
||||
deps: &Deps,
|
||||
) -> Result<(), ApplicationError> {
|
||||
let tagged = deps.entries.count_tagged_with(activity_id).await?;
|
||||
|
||||
if tagged > 0 {
|
||||
return Err(ApplicationError::Validation(format!(
|
||||
"{tagged} entries are tagged with this activity, so deleting it would rewrite them: archive it instead"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use domain::api_token::TokenScopes;
|
||||
use domain::provider::ProviderName;
|
||||
use domain::user::UserId;
|
||||
|
||||
@@ -5,4 +6,5 @@ use domain::user::UserId;
|
||||
pub struct MintApiTokenCommand {
|
||||
pub user_id: UserId,
|
||||
pub name: ProviderName,
|
||||
pub scopes: TokenScopes,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::api_token::ApiToken;
|
||||
use domain::api_token::{ApiToken, TokenScope};
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::{ApiTokenCommandPort, ApiTokenQueryPort, ApiTokenSecretPort};
|
||||
|
||||
@@ -13,7 +13,11 @@ pub struct Deps {
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(secret, deps))]
|
||||
pub async fn execute(secret: &str, deps: &Deps) -> Result<ApiToken, ApplicationError> {
|
||||
pub async fn execute(
|
||||
secret: &str,
|
||||
needed: TokenScope,
|
||||
deps: &Deps,
|
||||
) -> Result<ApiToken, ApplicationError> {
|
||||
let digest = deps.secrets.digest(secret);
|
||||
|
||||
let token = deps
|
||||
@@ -22,6 +26,17 @@ pub async fn execute(secret: &str, deps: &Deps) -> Result<ApiToken, ApplicationE
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::Unauthorized("this api token is not valid".into()))?;
|
||||
|
||||
if !token.allows(needed) {
|
||||
tracing::warn!(
|
||||
token_id = %token.id(),
|
||||
%needed,
|
||||
granted = %token.scopes(),
|
||||
"an api token was presented for something it does not grant"
|
||||
);
|
||||
|
||||
return Err(DomainError::Forbidden(format!("this token does not grant {needed}")).into());
|
||||
}
|
||||
|
||||
if let Err(error) = deps.command.mark_used(token.id()).await {
|
||||
tracing::warn!(token_id = %token.id(), %error, "could not record that a token was used");
|
||||
}
|
||||
|
||||
@@ -19,10 +19,15 @@ pub async fn execute(
|
||||
let secret = deps.secrets.mint();
|
||||
let digest = deps.secrets.digest(&secret);
|
||||
|
||||
let token = ApiToken::new(command.user_id, command.name, digest);
|
||||
let token = ApiToken::new(command.user_id, command.name, digest, command.scopes);
|
||||
deps.command.save(&token).await?;
|
||||
|
||||
tracing::info!(token_id = %token.id(), name = token.name().value(), "minted an api token");
|
||||
tracing::info!(
|
||||
token_id = %token.id(),
|
||||
name = token.name().value(),
|
||||
scopes = %token.scopes(),
|
||||
"minted an api token"
|
||||
);
|
||||
|
||||
Ok(MintedApiToken::new(token, secret))
|
||||
}
|
||||
|
||||
@@ -6,18 +6,20 @@ use domain::correlation::{
|
||||
Adjustment, Agreement, Coefficient, CorrelationInput, CorrelationStrategy, Observation, PValue,
|
||||
Tested,
|
||||
};
|
||||
use domain::dimension::ComposedEntry;
|
||||
use domain::entry::{Date, DayMood};
|
||||
use domain::metric::MetricKind;
|
||||
use domain::moon::MoonPhase;
|
||||
use domain::ports::{
|
||||
ActivityQueryPort, CycleStartQueryPort, DailyMetricQueryPort, MoodEntryQueryPort,
|
||||
UserPreferencesQueryPort, UserQueryPort,
|
||||
ActivityQueryPort, CycleStartQueryPort, DailyMetricQueryPort, EntryDimensionPort,
|
||||
MoodEntryQueryPort, UserPreferencesQueryPort, UserQueryPort,
|
||||
};
|
||||
use domain::user::{Timezone, UserId};
|
||||
use domain::user::Timezone;
|
||||
|
||||
use crate::correlation::queries::CorrelationQuery;
|
||||
use crate::cycle::use_cases::read_cycle::calendar_of;
|
||||
use crate::day::{date_of, day_moods, instants_of, timezone_for};
|
||||
use crate::entry::composition::EntryComposer;
|
||||
use crate::errors::ApplicationError;
|
||||
use crate::user::preferences::preferences_of;
|
||||
|
||||
@@ -43,7 +45,8 @@ pub struct Deps {
|
||||
pub metrics: Arc<dyn DailyMetricQueryPort>,
|
||||
pub activities: Arc<dyn ActivityQueryPort>,
|
||||
pub cycles: Arc<dyn CycleStartQueryPort>,
|
||||
pub weather_store: Arc<dyn domain::ports::EntryDimensionPort>,
|
||||
pub weather_store: Arc<dyn EntryDimensionPort>,
|
||||
pub activity_store: Arc<dyn EntryDimensionPort>,
|
||||
pub preferences: Arc<dyn UserPreferencesQueryPort>,
|
||||
pub users: Arc<dyn UserQueryPort>,
|
||||
}
|
||||
@@ -63,6 +66,13 @@ pub async fn execute(
|
||||
let mut moods = day_moods(&entries, &timezone);
|
||||
moods.retain(|date, _| query.span.contains(date));
|
||||
|
||||
let composed = EntryComposer::new(vec![
|
||||
deps.weather_store.clone(),
|
||||
deps.activity_store.clone(),
|
||||
])
|
||||
.compose(entries)
|
||||
.await?;
|
||||
|
||||
let mut scored = metric_rows(&query, deps, &moods).await?;
|
||||
|
||||
let lunar = paired(&moods, |date| Some(MoonPhase::on(date).illumination()));
|
||||
@@ -77,9 +87,9 @@ pub async fn execute(
|
||||
scored.push(cycle);
|
||||
}
|
||||
|
||||
scored.push(temperature_row(&query, deps, &moods, &timezone).await?);
|
||||
scored.push(temperature_row(&query, &composed, &moods, &timezone));
|
||||
|
||||
scored.extend(activity_rows(&query, deps, &moods, &timezone).await?);
|
||||
scored.extend(activity_rows(&query, deps, &composed, &moods, &timezone).await?);
|
||||
|
||||
Ok(mark_what_holds_up(scored, query.false_discovery_rate))
|
||||
}
|
||||
@@ -144,56 +154,60 @@ async fn cycle_row(
|
||||
)))
|
||||
}
|
||||
|
||||
async fn temperature_row(
|
||||
fn temperature_row(
|
||||
query: &CorrelationQuery,
|
||||
deps: &Deps,
|
||||
composed: &[ComposedEntry],
|
||||
moods: &BTreeMap<Date, DayMood>,
|
||||
timezone: &Timezone,
|
||||
) -> Result<Pending, ApplicationError> {
|
||||
let entries = deps
|
||||
.entries
|
||||
.find_by_date_range(&query.user_id, &instants_of(&query.span))
|
||||
.await?;
|
||||
|
||||
let composed = crate::entry::composition::EntryComposer::new(vec![deps.weather_store.clone()])
|
||||
.compose(entries)
|
||||
.await?;
|
||||
|
||||
let mut warmest: BTreeMap<Date, f64> = BTreeMap::new();
|
||||
for entry in &composed {
|
||||
if let Some(weather) = entry.weather() {
|
||||
warmest.insert(
|
||||
date_of(&entry.entry, timezone),
|
||||
weather.temperature().value(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
) -> Pending {
|
||||
let warmest = warmest_by_date(composed, timezone);
|
||||
let observations = paired(moods, |date| warmest.get(date).copied());
|
||||
|
||||
Ok(row(
|
||||
row(
|
||||
CorrelationInput::Temperature,
|
||||
None,
|
||||
&observations,
|
||||
query.minimum_sample_size,
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
fn warmest_by_date(composed: &[ComposedEntry], timezone: &Timezone) -> BTreeMap<Date, f64> {
|
||||
let mut warmest: BTreeMap<Date, f64> = BTreeMap::new();
|
||||
|
||||
for entry in composed {
|
||||
let Some(weather) = entry.weather() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let date = date_of(&entry.entry, timezone);
|
||||
let temperature = weather.temperature().value();
|
||||
|
||||
warmest
|
||||
.entry(date)
|
||||
.and_modify(|highest| *highest = highest.max(temperature))
|
||||
.or_insert(temperature);
|
||||
}
|
||||
|
||||
warmest
|
||||
}
|
||||
|
||||
async fn activity_rows(
|
||||
query: &CorrelationQuery,
|
||||
deps: &Deps,
|
||||
composed: &[ComposedEntry],
|
||||
moods: &BTreeMap<Date, DayMood>,
|
||||
timezone: &Timezone,
|
||||
) -> Result<Vec<Pending>, ApplicationError> {
|
||||
let catalog = deps.activities.find_active_by_user(&query.user_id).await?;
|
||||
let mut rows = Vec::with_capacity(catalog.len());
|
||||
let catalogue = deps.activities.find_active_by_user(&query.user_id).await?;
|
||||
let tagged = days_tagged_by_activity(composed, timezone);
|
||||
let mut rows = Vec::with_capacity(catalogue.len());
|
||||
|
||||
for activity in catalog {
|
||||
let days = days_tagged_with(&query.user_id, activity.id(), deps, timezone).await?;
|
||||
for activity in catalogue {
|
||||
let days = tagged.get(activity.id());
|
||||
|
||||
if never_logged(&days, moods) {
|
||||
let Some(days) = days.filter(|days| ever_logged(days, moods)) else {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let observations = paired(moods, |date| {
|
||||
Some(if days.contains(date) { PRESENT } else { ABSENT })
|
||||
@@ -210,22 +224,25 @@ async fn activity_rows(
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn never_logged(days: &BTreeSet<Date>, moods: &BTreeMap<Date, DayMood>) -> bool {
|
||||
!days.iter().any(|date| moods.contains_key(date))
|
||||
fn days_tagged_by_activity(
|
||||
composed: &[ComposedEntry],
|
||||
timezone: &Timezone,
|
||||
) -> BTreeMap<ActivityId, BTreeSet<Date>> {
|
||||
let mut tagged: BTreeMap<ActivityId, BTreeSet<Date>> = BTreeMap::new();
|
||||
|
||||
for entry in composed {
|
||||
let date = date_of(&entry.entry, timezone);
|
||||
|
||||
for activity_id in entry.activities() {
|
||||
tagged.entry(activity_id.clone()).or_default().insert(date);
|
||||
}
|
||||
}
|
||||
|
||||
tagged
|
||||
}
|
||||
|
||||
async fn days_tagged_with(
|
||||
user_id: &UserId,
|
||||
activity_id: &ActivityId,
|
||||
deps: &Deps,
|
||||
timezone: &Timezone,
|
||||
) -> Result<BTreeSet<Date>, ApplicationError> {
|
||||
let tagged = deps.entries.find_by_activity(user_id, activity_id).await?;
|
||||
|
||||
Ok(tagged
|
||||
.iter()
|
||||
.map(|entry| date_of(entry, timezone))
|
||||
.collect())
|
||||
fn ever_logged(days: &BTreeSet<Date>, moods: &BTreeMap<Date, DayMood>) -> bool {
|
||||
days.iter().any(|date| moods.contains_key(date))
|
||||
}
|
||||
|
||||
fn paired(
|
||||
@@ -249,15 +266,14 @@ fn mark_what_holds_up(pending: Vec<Pending>, false_discovery_rate: f64) -> Vec<C
|
||||
|
||||
let tested: Vec<Tested> = places
|
||||
.iter()
|
||||
.map(|(row_index, score_index)| {
|
||||
.filter_map(|(row_index, score_index)| {
|
||||
let entry = &pending[*row_index];
|
||||
|
||||
Tested {
|
||||
Some(Tested {
|
||||
family: entry.row.input.family(),
|
||||
strategy: entry.row.scores[*score_index].strategy,
|
||||
p_value: entry.significance[*score_index]
|
||||
.expect("only places with a p-value are collected"),
|
||||
}
|
||||
p_value: entry.significance[*score_index]?,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ pub struct CreateEntryCommand {
|
||||
#[derive(Debug)]
|
||||
pub struct UpdateEntryCommand {
|
||||
pub entry_id: MoodEntryId,
|
||||
pub mood: Mood,
|
||||
pub mood: Option<Mood>,
|
||||
pub logged_at: Option<DateTime<FixedOffset>>,
|
||||
pub dimensions: Vec<DimensionValue>,
|
||||
pub dimensions: Option<Vec<DimensionValue>>,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod commands;
|
||||
pub mod composition;
|
||||
pub mod queries;
|
||||
pub mod tagging;
|
||||
pub mod use_cases;
|
||||
|
||||
@@ -1,25 +1,10 @@
|
||||
use domain::activity::ActivityId;
|
||||
use domain::entry::{DateRange, Mood};
|
||||
use domain::entry::{DateRange, EntrySelection, Pagination};
|
||||
use domain::user::UserId;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ListEntriesQuery {
|
||||
pub user_id: UserId,
|
||||
pub range: Option<DateRange>,
|
||||
pub limit: Option<i64>,
|
||||
pub offset: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct FilterByMoodQuery {
|
||||
pub user_id: UserId,
|
||||
pub mood: Mood,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct FilterByActivityQuery {
|
||||
pub user_id: UserId,
|
||||
pub activity_id: ActivityId,
|
||||
pub selection: EntrySelection,
|
||||
pub page: Pagination,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
63
crates/application/src/entry/tagging.rs
Normal file
63
crates/application/src/entry/tagging.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::activity::{Activity, ActivityId};
|
||||
use domain::dimension::{DimensionValue, lookup};
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::ActivityQueryPort;
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub async fn verify_activities_may_be_tagged(
|
||||
owner: &UserId,
|
||||
wanted: &[DimensionValue],
|
||||
already_worn: &[ActivityId],
|
||||
activities: &Arc<dyn ActivityQueryPort>,
|
||||
) -> Result<(), ApplicationError> {
|
||||
let tagged = lookup::activities_in(wanted);
|
||||
|
||||
if tagged.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let catalogue = catalogue_of(owner, activities).await?;
|
||||
|
||||
for activity_id in tagged {
|
||||
let activity = catalogue
|
||||
.get(activity_id)
|
||||
.ok_or_else(|| not_in_this_catalogue(activity_id))?;
|
||||
|
||||
if activity.is_archived() && !already_worn.contains(activity_id) {
|
||||
return Err(retired(activity));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn catalogue_of(
|
||||
owner: &UserId,
|
||||
activities: &Arc<dyn ActivityQueryPort>,
|
||||
) -> Result<HashMap<ActivityId, Activity>, ApplicationError> {
|
||||
Ok(activities
|
||||
.find_by_user(owner)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|activity| (activity.id().clone(), activity))
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn not_in_this_catalogue(activity_id: &ActivityId) -> ApplicationError {
|
||||
DomainError::Forbidden(format!(
|
||||
"activity {activity_id} is not in this account's catalogue"
|
||||
))
|
||||
.into()
|
||||
}
|
||||
|
||||
fn retired(activity: &Activity) -> ApplicationError {
|
||||
ApplicationError::Validation(format!(
|
||||
"{} is archived, so it cannot be tagged onto an entry it was not already on: unarchive it first",
|
||||
activity.name().value()
|
||||
))
|
||||
}
|
||||
65
crates/application/src/entry/use_cases/create_entries.rs
Normal file
65
crates/application/src/entry/use_cases/create_entries.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::entry::MoodEntry;
|
||||
use domain::events::{DomainEvent, EventEnvelope};
|
||||
use domain::ports::{
|
||||
ActivityQueryPort, EntryDimensionPort, EventPublisherPort, MoodEntryCommandPort,
|
||||
};
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::entry::tagging::verify_activities_may_be_tagged;
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
use super::super::commands::CreateEntryCommand;
|
||||
|
||||
const NOTHING_YET: &[domain::activity::ActivityId] = &[];
|
||||
|
||||
pub struct Deps {
|
||||
pub entries: Arc<dyn MoodEntryCommandPort>,
|
||||
pub dimensions: Vec<Arc<dyn EntryDimensionPort>>,
|
||||
pub activities: Arc<dyn ActivityQueryPort>,
|
||||
pub events: Arc<dyn EventPublisherPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps, wanted), fields(count = wanted.len()))]
|
||||
pub async fn execute(
|
||||
user_id: UserId,
|
||||
wanted: Vec<CreateEntryCommand>,
|
||||
deps: &Deps,
|
||||
) -> Result<Vec<MoodEntry>, ApplicationError> {
|
||||
for one in &wanted {
|
||||
verify_activities_may_be_tagged(&user_id, &one.dimensions, NOTHING_YET, &deps.activities)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let written: Vec<(MoodEntry, Vec<domain::dimension::DimensionValue>)> = wanted
|
||||
.into_iter()
|
||||
.map(|one| {
|
||||
(
|
||||
MoodEntry::new(one.user_id, one.mood, one.logged_at),
|
||||
one.dimensions,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let entries: Vec<MoodEntry> = written.iter().map(|(entry, _)| entry.clone()).collect();
|
||||
deps.entries.save_batch(&entries).await?;
|
||||
|
||||
for (entry, dimensions) in &written {
|
||||
for port in &deps.dimensions {
|
||||
port.save(entry.id(), dimensions).await?;
|
||||
}
|
||||
}
|
||||
|
||||
for entry in &entries {
|
||||
let event = DomainEvent::EntryCreated {
|
||||
entry_id: entry.id().clone(),
|
||||
user_id: entry.user_id().clone(),
|
||||
};
|
||||
deps.events.publish(EventEnvelope::wrap(event)).await?;
|
||||
}
|
||||
|
||||
tracing::info!(created = entries.len(), "created entries in one request");
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
@@ -2,20 +2,29 @@ use std::sync::Arc;
|
||||
|
||||
use domain::entry::MoodEntry;
|
||||
use domain::events::{DomainEvent, EventEnvelope};
|
||||
use domain::ports::{EntryDimensionPort, EventPublisherPort, MoodEntryCommandPort};
|
||||
use domain::ports::{
|
||||
ActivityQueryPort, EntryDimensionPort, EventPublisherPort, MoodEntryCommandPort,
|
||||
};
|
||||
|
||||
use crate::entry::tagging::verify_activities_may_be_tagged;
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
use super::super::commands::CreateEntryCommand;
|
||||
|
||||
const NOTHING_YET: &[domain::activity::ActivityId] = &[];
|
||||
|
||||
pub struct Deps {
|
||||
pub entries: Arc<dyn MoodEntryCommandPort>,
|
||||
pub dimensions: Vec<Arc<dyn EntryDimensionPort>>,
|
||||
pub activities: Arc<dyn ActivityQueryPort>,
|
||||
pub events: Arc<dyn EventPublisherPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(cmd: CreateEntryCommand, deps: &Deps) -> Result<MoodEntry, ApplicationError> {
|
||||
verify_activities_may_be_tagged(&cmd.user_id, &cmd.dimensions, NOTHING_YET, &deps.activities)
|
||||
.await?;
|
||||
|
||||
let entry = MoodEntry::new(cmd.user_id, cmd.mood, cmd.logged_at);
|
||||
|
||||
deps.entries.save(&entry).await?;
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::entry::MoodEntry;
|
||||
use domain::ports::MoodEntryQueryPort;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
use super::super::queries::FilterByActivityQuery;
|
||||
|
||||
pub struct Deps {
|
||||
pub query: Arc<dyn MoodEntryQueryPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(
|
||||
query: FilterByActivityQuery,
|
||||
deps: &Deps,
|
||||
) -> Result<Vec<MoodEntry>, ApplicationError> {
|
||||
let entries = deps
|
||||
.query
|
||||
.find_by_activity(&query.user_id, &query.activity_id)
|
||||
.await?;
|
||||
Ok(entries)
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::entry::MoodEntry;
|
||||
use domain::ports::MoodEntryQueryPort;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
use super::super::queries::FilterByMoodQuery;
|
||||
|
||||
pub struct Deps {
|
||||
pub query: Arc<dyn MoodEntryQueryPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(
|
||||
query: FilterByMoodQuery,
|
||||
deps: &Deps,
|
||||
) -> Result<Vec<MoodEntry>, ApplicationError> {
|
||||
let entries = deps.query.find_by_mood(&query.user_id, query.mood).await?;
|
||||
Ok(entries)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::entry::Mood;
|
||||
use domain::entry::{Date, Mood};
|
||||
use domain::ports::{MoodEntryQueryPort, UserQueryPort};
|
||||
use domain::services::MoodAnalyzerService;
|
||||
|
||||
@@ -14,6 +14,8 @@ pub struct MoodStats {
|
||||
pub frequency: Vec<(Mood, usize)>,
|
||||
pub current_streak: usize,
|
||||
pub total_entries: usize,
|
||||
pub first_logged_on: Option<Date>,
|
||||
pub last_logged_on: Option<Date>,
|
||||
}
|
||||
|
||||
pub struct Deps {
|
||||
@@ -29,7 +31,7 @@ pub async fn execute(query: MoodStatsQuery, deps: &Deps) -> Result<MoodStats, Ap
|
||||
.find_by_date_range(&query.user_id, &range)
|
||||
.await?
|
||||
}
|
||||
None => deps.query.find_by_user(&query.user_id, None, None).await?,
|
||||
None => deps.query.find_all_by_user(&query.user_id).await?,
|
||||
};
|
||||
|
||||
let timezone = timezone_for(&query.user_id, &deps.users).await?;
|
||||
@@ -43,5 +45,7 @@ pub async fn execute(query: MoodStatsQuery, deps: &Deps) -> Result<MoodStats, Ap
|
||||
frequency: MoodAnalyzerService::mood_frequency(&entries),
|
||||
current_streak: MoodAnalyzerService::current_streak(&dates, today_in(&timezone)),
|
||||
total_entries: entries.len(),
|
||||
first_logged_on: dates.iter().min().copied(),
|
||||
last_logged_on: dates.iter().max().copied(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::entry::MoodEntry;
|
||||
use domain::entry::{MoodEntry, Page};
|
||||
use domain::ports::MoodEntryQueryPort;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
@@ -15,19 +15,9 @@ pub struct Deps {
|
||||
pub async fn execute(
|
||||
query: ListEntriesQuery,
|
||||
deps: &Deps,
|
||||
) -> Result<Vec<MoodEntry>, ApplicationError> {
|
||||
let entries = match query.range {
|
||||
Some(range) => {
|
||||
deps.query
|
||||
.find_by_date_range(&query.user_id, &range)
|
||||
.await?
|
||||
}
|
||||
None => {
|
||||
deps.query
|
||||
.find_by_user(&query.user_id, query.limit, query.offset)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
) -> Result<Page<MoodEntry>, ApplicationError> {
|
||||
let total = deps.query.count(&query.selection).await?;
|
||||
let entries = deps.query.select(&query.selection, query.page).await?;
|
||||
|
||||
Ok(entries)
|
||||
Ok(Page::new(entries, total, query.page))
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
pub mod create_entries;
|
||||
pub mod create_entry;
|
||||
pub mod delete_entries_by_date_range;
|
||||
pub mod delete_entry;
|
||||
pub mod filter_by_activity;
|
||||
pub mod filter_by_mood;
|
||||
pub mod get_calendar;
|
||||
pub mod get_entry;
|
||||
pub mod get_mood_stats;
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::dimension::lookup;
|
||||
use domain::dimension::DimensionValue;
|
||||
use domain::entry::MoodEntry;
|
||||
use domain::errors::DomainError;
|
||||
use domain::events::{DomainEvent, EventEnvelope};
|
||||
use domain::ports::{
|
||||
EntryDimensionPort, EventPublisherPort, MediaStoragePort, MoodEntryCommandPort,
|
||||
MoodEntryQueryPort,
|
||||
ActivityQueryPort, EntryDimensionPort, EventPublisherPort, MediaStoragePort,
|
||||
MoodEntryCommandPort, MoodEntryQueryPort,
|
||||
};
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::authorization::verify_ownership;
|
||||
use crate::entry::composition::EntryComposer;
|
||||
use crate::entry::tagging::verify_activities_may_be_tagged;
|
||||
use crate::errors::ApplicationError;
|
||||
use crate::media::dropped::{DroppedMedia, delete_dropped};
|
||||
|
||||
use super::super::commands::UpdateEntryCommand;
|
||||
|
||||
@@ -20,6 +22,7 @@ pub struct Deps {
|
||||
pub command: Arc<dyn MoodEntryCommandPort>,
|
||||
pub dimensions: Vec<Arc<dyn EntryDimensionPort>>,
|
||||
pub query: Arc<dyn MoodEntryQueryPort>,
|
||||
pub activities: Arc<dyn ActivityQueryPort>,
|
||||
pub media_storage: Arc<dyn MediaStoragePort>,
|
||||
pub events: Arc<dyn EventPublisherPort>,
|
||||
}
|
||||
@@ -38,38 +41,28 @@ pub async fn execute(
|
||||
|
||||
verify_ownership(entry.user_id(), &caller_id)?;
|
||||
|
||||
let previous = EntryComposer::new(deps.dimensions.clone())
|
||||
.compose(vec![entry.clone()])
|
||||
.await?;
|
||||
let old_photos = previous[0].photos().to_vec();
|
||||
let old_memos = previous[0].voice_memos().to_vec();
|
||||
let dropped = match &cmd.dimensions {
|
||||
Some(wanted) => Some(replace_dimensions(&entry, wanted, &caller_id, deps).await?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
entry.update_mood(cmd.mood);
|
||||
if let Some(mood) = cmd.mood {
|
||||
entry.update_mood(mood);
|
||||
}
|
||||
if let Some(logged_at) = cmd.logged_at {
|
||||
entry.update_logged_at(logged_at);
|
||||
}
|
||||
|
||||
let new_photos = lookup::photos_in(&cmd.dimensions);
|
||||
let new_memos = lookup::voice_memos_in(&cmd.dimensions);
|
||||
|
||||
for photo_id in &old_photos {
|
||||
if !new_photos.contains(photo_id)
|
||||
&& let Err(e) = deps.media_storage.delete_photo(photo_id).await
|
||||
{
|
||||
tracing::warn!(%e, "failed to delete removed photo blob");
|
||||
}
|
||||
}
|
||||
for memo_id in &old_memos {
|
||||
if !new_memos.contains(memo_id)
|
||||
&& let Err(e) = deps.media_storage.delete_voice_memo(memo_id).await
|
||||
{
|
||||
tracing::warn!(%e, "failed to delete removed voice memo blob");
|
||||
}
|
||||
}
|
||||
|
||||
deps.command.save(&entry).await?;
|
||||
for port in &deps.dimensions {
|
||||
port.save(entry.id(), &cmd.dimensions).await?;
|
||||
|
||||
if let Some(wanted) = &cmd.dimensions {
|
||||
for port in &deps.dimensions {
|
||||
port.save(entry.id(), wanted).await?;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(dropped) = dropped {
|
||||
delete_dropped(&dropped, &deps.media_storage).await;
|
||||
}
|
||||
|
||||
let event = DomainEvent::EntryUpdated {
|
||||
@@ -80,3 +73,24 @@ pub async fn execute(
|
||||
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
async fn replace_dimensions(
|
||||
entry: &MoodEntry,
|
||||
wanted: &[DimensionValue],
|
||||
caller_id: &UserId,
|
||||
deps: &Deps,
|
||||
) -> Result<DroppedMedia, ApplicationError> {
|
||||
let previous = EntryComposer::new(deps.dimensions.clone())
|
||||
.compose(vec![entry.clone()])
|
||||
.await?;
|
||||
|
||||
verify_activities_may_be_tagged(
|
||||
caller_id,
|
||||
wanted,
|
||||
previous[0].activities(),
|
||||
&deps.activities,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(DroppedMedia::between(&previous[0], wanted))
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ pub struct Deps {
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(user_id: UserId, deps: &Deps) -> Result<Vec<u8>, ApplicationError> {
|
||||
let entries = EntryComposer::new(deps.dimensions.clone())
|
||||
.compose(deps.entries.find_by_user(&user_id, None, None).await?)
|
||||
.compose(deps.entries.find_all_by_user(&user_id).await?)
|
||||
.await?;
|
||||
|
||||
let media = gather_media(&entries, deps).await?;
|
||||
@@ -81,26 +81,46 @@ async fn gather_media(
|
||||
) -> Result<BackupMedia, ApplicationError> {
|
||||
let mut photos = Vec::new();
|
||||
let mut voice_memos = Vec::new();
|
||||
let mut missing: Vec<String> = Vec::new();
|
||||
|
||||
for entry in entries {
|
||||
for photo_id in entry.photos() {
|
||||
if let Some(file) = deps.media_storage.get_photo(photo_id).await? {
|
||||
photos.push(MediaBlob {
|
||||
match deps.media_storage.get_photo(photo_id).await {
|
||||
Ok(Some(file)) => photos.push(MediaBlob {
|
||||
id: photo_id.value().to_string(),
|
||||
data: file.data,
|
||||
});
|
||||
content_type: file.content_type,
|
||||
}),
|
||||
Ok(None) => missing.push(format!("photo {photo_id}")),
|
||||
Err(error) => {
|
||||
tracing::warn!(%photo_id, %error, "a photo could not be read for the backup");
|
||||
missing.push(format!("photo {photo_id}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
for memo_id in entry.voice_memos() {
|
||||
if let Some(file) = deps.media_storage.get_voice_memo(memo_id).await? {
|
||||
voice_memos.push(MediaBlob {
|
||||
match deps.media_storage.get_voice_memo(memo_id).await {
|
||||
Ok(Some(file)) => voice_memos.push(MediaBlob {
|
||||
id: memo_id.value().to_string(),
|
||||
data: file.data,
|
||||
});
|
||||
content_type: file.content_type,
|
||||
}),
|
||||
Ok(None) => missing.push(format!("voice memo {memo_id}")),
|
||||
Err(error) => {
|
||||
tracing::warn!(%memo_id, %error, "a voice memo could not be read for the backup");
|
||||
missing.push(format!("voice memo {memo_id}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !missing.is_empty() {
|
||||
tracing::warn!(
|
||||
missing = missing.len(),
|
||||
"some media could not be read, so the backup carries the rest"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(BackupMedia {
|
||||
photos,
|
||||
voice_memos,
|
||||
|
||||
@@ -18,7 +18,7 @@ pub struct Deps {
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(user_id: UserId, deps: &Deps) -> Result<Vec<u8>, ApplicationError> {
|
||||
let entries = EntryComposer::new(deps.dimensions.clone())
|
||||
.compose(deps.entries.find_by_user(&user_id, None, None).await?)
|
||||
.compose(deps.entries.find_all_by_user(&user_id).await?)
|
||||
.await?;
|
||||
|
||||
let extract = SharedExtract {
|
||||
|
||||
41
crates/application/src/import/already_here.rs
Normal file
41
crates/application/src/import/already_here.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use chrono::{DateTime, FixedOffset};
|
||||
|
||||
use domain::entry::{Mood, MoodEntry};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
struct Fingerprint {
|
||||
instant: i64,
|
||||
mood: u8,
|
||||
}
|
||||
|
||||
impl Fingerprint {
|
||||
fn of(logged_at: &DateTime<FixedOffset>, mood: Mood) -> Self {
|
||||
Self {
|
||||
instant: logged_at.timestamp(),
|
||||
mood: mood.value(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AlreadyHere(HashSet<Fingerprint>);
|
||||
|
||||
impl AlreadyHere {
|
||||
pub fn holding(entries: &[MoodEntry]) -> Self {
|
||||
Self(
|
||||
entries
|
||||
.iter()
|
||||
.map(|entry| Fingerprint::of(entry.logged_at(), entry.mood()))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn holds(&self, logged_at: &DateTime<FixedOffset>, mood: Mood) -> bool {
|
||||
self.0.contains(&Fingerprint::of(logged_at, mood))
|
||||
}
|
||||
|
||||
pub fn remember(&mut self, logged_at: &DateTime<FixedOffset>, mood: Mood) {
|
||||
self.0.insert(Fingerprint::of(logged_at, mood));
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod already_here;
|
||||
pub mod commands;
|
||||
pub mod use_cases;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::FixedOffset;
|
||||
@@ -14,6 +14,7 @@ use domain::ports::{
|
||||
use domain::user::Timezone;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
use crate::import::already_here::AlreadyHere;
|
||||
|
||||
use super::super::commands::ImportCommand;
|
||||
|
||||
@@ -42,14 +43,8 @@ pub async fn execute(cmd: ImportCommand, deps: &Deps) -> Result<ImportResult, Ap
|
||||
|
||||
let timezone = crate::day::timezone_for(&cmd.user_id, &deps.users).await?;
|
||||
|
||||
let existing_entries = deps
|
||||
.entry_query
|
||||
.find_by_user(&cmd.user_id, None, None)
|
||||
.await?;
|
||||
let existing_keys: HashSet<(String, u8)> = existing_entries
|
||||
.iter()
|
||||
.map(|e| (e.logged_at().to_rfc3339(), e.mood().value()))
|
||||
.collect();
|
||||
let existing_entries = deps.entry_query.find_all_by_user(&cmd.user_id).await?;
|
||||
let mut already_here = AlreadyHere::holding(&existing_entries);
|
||||
|
||||
let existing_activities = deps.activity_query.find_by_user(&cmd.user_id).await?;
|
||||
let mut activity_cache: HashMap<String, ActivityId> = existing_activities
|
||||
@@ -74,7 +69,7 @@ pub async fn execute(cmd: ImportCommand, deps: &Deps) -> Result<ImportResult, Ap
|
||||
&cmd.user_id,
|
||||
&mut activity_cache,
|
||||
&category_map,
|
||||
&existing_keys,
|
||||
&mut already_here,
|
||||
&timezone,
|
||||
deps,
|
||||
)
|
||||
@@ -118,17 +113,17 @@ async fn build_entry(
|
||||
user_id: &domain::user::UserId,
|
||||
activity_cache: &mut HashMap<String, ActivityId>,
|
||||
category_map: &HashMap<String, String>,
|
||||
existing_keys: &HashSet<(String, u8)>,
|
||||
already_here: &mut AlreadyHere,
|
||||
timezone: &Timezone,
|
||||
deps: &Deps,
|
||||
) -> Result<Option<ComposedEntry>, ApplicationError> {
|
||||
let mood = Mood::try_from(row.mood)?;
|
||||
let logged_at = the_users_own_wall_clock(&row.date, &row.time, timezone)?;
|
||||
|
||||
let key = (logged_at.to_rfc3339(), mood.value());
|
||||
if existing_keys.contains(&key) {
|
||||
if already_here.holds(&logged_at, mood) {
|
||||
return Ok(None);
|
||||
}
|
||||
already_here.remember(&logged_at, mood);
|
||||
|
||||
let mut activity_ids = Vec::new();
|
||||
for activity_name in &row.activities {
|
||||
|
||||
15
crates/application/src/job/use_cases/list_exhausted_jobs.rs
Normal file
15
crates/application/src/job/use_cases/list_exhausted_jobs.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::job::Job;
|
||||
use domain::ports::JobQueueQueryPort;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct Deps {
|
||||
pub jobs: Arc<dyn JobQueueQueryPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(most: usize, deps: &Deps) -> Result<Vec<Job>, ApplicationError> {
|
||||
Ok(deps.jobs.find_exhausted(most).await?)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod list_exhausted_jobs;
|
||||
pub mod run_due_jobs;
|
||||
pub mod sweep_recording_backlog;
|
||||
pub mod sweep_weather_backlog;
|
||||
|
||||
@@ -95,13 +95,13 @@ async fn observe_weather(job: &Job, deps: &Deps) -> Result<(), String> {
|
||||
return Err("weather lookups are switched off".into());
|
||||
};
|
||||
|
||||
let waiting = deps
|
||||
let found = deps
|
||||
.places
|
||||
.find_places_without_weather(usize::MAX)
|
||||
.find_place_without_weather(entry_id)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
let Some(place) = waiting.iter().find(|place| &place.entry_id == entry_id) else {
|
||||
let Some(place) = found else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
@@ -116,7 +116,7 @@ async fn observe_weather(job: &Job, deps: &Deps) -> Result<(), String> {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
remember_weather(place, weather, deps).await
|
||||
remember_weather(&place, weather, deps).await
|
||||
}
|
||||
|
||||
async fn remember_weather(
|
||||
@@ -133,13 +133,13 @@ async fn remember_weather(
|
||||
async fn backfill_recording_identity(job: &Job, deps: &Deps) -> Result<(), String> {
|
||||
let JobSubject::Entry(entry_id) = job.subject();
|
||||
|
||||
let waiting = deps
|
||||
let found = deps
|
||||
.backfill
|
||||
.find_songs_without_a_recording(usize::MAX)
|
||||
.find_song_without_a_recording(entry_id)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
let Some(song) = waiting.iter().find(|song| &song.entry_id == entry_id) else {
|
||||
let Some(song) = found else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
@@ -155,7 +155,7 @@ async fn backfill_recording_identity(job: &Job, deps: &Deps) -> Result<(), Strin
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
remember(song, &recording_id, deps).await
|
||||
remember(&song, &recording_id, deps).await
|
||||
}
|
||||
|
||||
async fn remember(
|
||||
|
||||
@@ -1,19 +1,51 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::attachment::MediaRef;
|
||||
use domain::dimension::ComposedEntry;
|
||||
use domain::ports::MediaStoragePort;
|
||||
use domain::ports::{MediaOwnershipPort, MediaStoragePort};
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub async fn delete_media_for(entries: &[ComposedEntry], storage: &Arc<dyn MediaStoragePort>) {
|
||||
for entry in entries {
|
||||
for photo_id in entry.photos() {
|
||||
if let Err(e) = storage.delete_photo(photo_id).await {
|
||||
tracing::warn!(%e, "failed to delete photo blob");
|
||||
if let Err(error) = storage.delete_photo(photo_id).await {
|
||||
tracing::warn!(%error, "failed to delete photo blob");
|
||||
}
|
||||
}
|
||||
for memo_id in entry.voice_memos() {
|
||||
if let Err(e) = storage.delete_voice_memo(memo_id).await {
|
||||
tracing::warn!(%e, "failed to delete voice memo blob");
|
||||
if let Err(error) = storage.delete_voice_memo(memo_id).await {
|
||||
tracing::warn!(%error, "failed to delete voice memo blob");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_everything_owned_by(
|
||||
owner: &UserId,
|
||||
storage: &Arc<dyn MediaStoragePort>,
|
||||
ownership: &Arc<dyn MediaOwnershipPort>,
|
||||
) -> Result<usize, ApplicationError> {
|
||||
let held = ownership.owned_by(owner).await?;
|
||||
|
||||
for media in &held {
|
||||
delete_one(*media, storage).await;
|
||||
}
|
||||
|
||||
Ok(held.len())
|
||||
}
|
||||
|
||||
async fn delete_one(media: MediaRef, storage: &Arc<dyn MediaStoragePort>) {
|
||||
if let Some(photo_id) = media.to_photo()
|
||||
&& let Err(error) = storage.delete_photo(&photo_id).await
|
||||
{
|
||||
tracing::warn!(%error, %media, "failed to delete photo blob");
|
||||
}
|
||||
|
||||
if let Some(memo_id) = media.to_voice_memo()
|
||||
&& let Err(error) = storage.delete_voice_memo(&memo_id).await
|
||||
{
|
||||
tracing::warn!(%error, %media, "failed to delete voice memo blob");
|
||||
}
|
||||
}
|
||||
|
||||
22
crates/application/src/media/custody.rs
Normal file
22
crates/application/src/media/custody.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::attachment::MediaRef;
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::MediaOwnershipPort;
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::authorization::verify_ownership;
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub async fn verify_custody(
|
||||
media: MediaRef,
|
||||
caller: &UserId,
|
||||
ownership: &Arc<dyn MediaOwnershipPort>,
|
||||
) -> Result<(), ApplicationError> {
|
||||
let owner = ownership
|
||||
.owner_of(media)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound(format!("{media} not found")))?;
|
||||
|
||||
verify_ownership(&owner, caller)
|
||||
}
|
||||
58
crates/application/src/media/dropped.rs
Normal file
58
crates/application/src/media/dropped.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::attachment::{PhotoId, VoiceMemoId};
|
||||
use domain::dimension::{ComposedEntry, DimensionValue, lookup};
|
||||
use domain::ports::MediaStoragePort;
|
||||
|
||||
pub struct DroppedMedia {
|
||||
photos: Vec<PhotoId>,
|
||||
voice_memos: Vec<VoiceMemoId>,
|
||||
}
|
||||
|
||||
impl DroppedMedia {
|
||||
pub fn between(previous: &ComposedEntry, kept: &[DimensionValue]) -> Self {
|
||||
let still_here_photos = lookup::photos_in(kept);
|
||||
let still_here_memos = lookup::voice_memos_in(kept);
|
||||
|
||||
Self {
|
||||
photos: previous
|
||||
.photos()
|
||||
.iter()
|
||||
.filter(|photo_id| !still_here_photos.contains(photo_id))
|
||||
.cloned()
|
||||
.collect(),
|
||||
voice_memos: previous
|
||||
.voice_memos()
|
||||
.iter()
|
||||
.filter(|memo_id| !still_here_memos.contains(memo_id))
|
||||
.cloned()
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.photos.is_empty() && self.voice_memos.is_empty()
|
||||
}
|
||||
|
||||
pub fn photos(&self) -> &[PhotoId] {
|
||||
&self.photos
|
||||
}
|
||||
|
||||
pub fn voice_memos(&self) -> &[VoiceMemoId] {
|
||||
&self.voice_memos
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_dropped(dropped: &DroppedMedia, storage: &Arc<dyn MediaStoragePort>) {
|
||||
for photo_id in dropped.photos() {
|
||||
if let Err(error) = storage.delete_photo(photo_id).await {
|
||||
tracing::warn!(%error, "failed to delete a photo dropped from an edit");
|
||||
}
|
||||
}
|
||||
|
||||
for memo_id in dropped.voice_memos() {
|
||||
if let Err(error) = storage.delete_voice_memo(memo_id).await {
|
||||
tracing::warn!(%error, "failed to delete a voice memo dropped from an edit");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
pub mod cleanup;
|
||||
pub mod custody;
|
||||
pub mod dropped;
|
||||
pub mod use_cases;
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::attachment::PhotoId;
|
||||
use domain::ports::MediaStoragePort;
|
||||
use domain::ports::{MediaOwnershipPort, MediaStoragePort};
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
use crate::media::custody::verify_custody;
|
||||
|
||||
pub struct Deps {
|
||||
pub storage: Arc<dyn MediaStoragePort>,
|
||||
pub ownership: Arc<dyn MediaOwnershipPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(photo_id: PhotoId, deps: &Deps) -> Result<(), ApplicationError> {
|
||||
pub async fn execute(
|
||||
photo_id: PhotoId,
|
||||
caller_id: UserId,
|
||||
deps: &Deps,
|
||||
) -> Result<(), ApplicationError> {
|
||||
let media = (&photo_id).into();
|
||||
verify_custody(media, &caller_id, &deps.ownership).await?;
|
||||
|
||||
deps.storage.delete_photo(&photo_id).await?;
|
||||
deps.ownership.forget(media).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::attachment::VoiceMemoId;
|
||||
use domain::ports::MediaStoragePort;
|
||||
use domain::ports::{MediaOwnershipPort, MediaStoragePort};
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
use crate::media::custody::verify_custody;
|
||||
|
||||
pub struct Deps {
|
||||
pub storage: Arc<dyn MediaStoragePort>,
|
||||
pub ownership: Arc<dyn MediaOwnershipPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(voice_memo_id: VoiceMemoId, deps: &Deps) -> Result<(), ApplicationError> {
|
||||
pub async fn execute(
|
||||
voice_memo_id: VoiceMemoId,
|
||||
caller_id: UserId,
|
||||
deps: &Deps,
|
||||
) -> Result<(), ApplicationError> {
|
||||
let media = (&voice_memo_id).into();
|
||||
verify_custody(media, &caller_id, &deps.ownership).await?;
|
||||
|
||||
deps.storage.delete_voice_memo(&voice_memo_id).await?;
|
||||
deps.ownership.forget(media).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
16
crates/application/src/media/use_cases/list_media.rs
Normal file
16
crates/application/src/media/use_cases/list_media.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::attachment::MediaRef;
|
||||
use domain::ports::MediaOwnershipPort;
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct Deps {
|
||||
pub ownership: Arc<dyn MediaOwnershipPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(owner: UserId, deps: &Deps) -> Result<Vec<MediaRef>, ApplicationError> {
|
||||
Ok(deps.ownership.owned_by(&owner).await?)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod delete_photo;
|
||||
pub mod delete_voice_memo;
|
||||
pub mod list_media;
|
||||
pub mod upload_photo;
|
||||
pub mod upload_voice_memo;
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::attachment::{MediaUpload, PhotoId};
|
||||
use domain::ports::MediaStoragePort;
|
||||
use domain::ports::{MediaOwnershipPort, MediaStoragePort};
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct Deps {
|
||||
pub storage: Arc<dyn MediaStoragePort>,
|
||||
pub ownership: Arc<dyn MediaOwnershipPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(upload: MediaUpload, deps: &Deps) -> Result<PhotoId, ApplicationError> {
|
||||
#[tracing::instrument(skip(deps, upload))]
|
||||
pub async fn execute(
|
||||
owner: UserId,
|
||||
upload: MediaUpload,
|
||||
deps: &Deps,
|
||||
) -> Result<PhotoId, ApplicationError> {
|
||||
let photo_id = deps.storage.store_photo(upload).await?;
|
||||
|
||||
deps.ownership.remember(&owner, (&photo_id).into()).await?;
|
||||
|
||||
Ok(photo_id)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::attachment::{MediaUpload, VoiceMemoId};
|
||||
use domain::ports::MediaStoragePort;
|
||||
use domain::ports::{MediaOwnershipPort, MediaStoragePort};
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct Deps {
|
||||
pub storage: Arc<dyn MediaStoragePort>,
|
||||
pub ownership: Arc<dyn MediaOwnershipPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(upload: MediaUpload, deps: &Deps) -> Result<VoiceMemoId, ApplicationError> {
|
||||
#[tracing::instrument(skip(deps, upload))]
|
||||
pub async fn execute(
|
||||
owner: UserId,
|
||||
upload: MediaUpload,
|
||||
deps: &Deps,
|
||||
) -> Result<VoiceMemoId, ApplicationError> {
|
||||
let voice_memo_id = deps.storage.store_voice_memo(upload).await?;
|
||||
|
||||
deps.ownership
|
||||
.remember(&owner, (&voice_memo_id).into())
|
||||
.await?;
|
||||
|
||||
Ok(voice_memo_id)
|
||||
}
|
||||
|
||||
19
crates/application/src/metric/use_cases/list_rejections.rs
Normal file
19
crates/application/src/metric/use_cases/list_rejections.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::RejectionQueryPort;
|
||||
use domain::rejection::RejectedMetric;
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct Deps {
|
||||
pub rejections: Arc<dyn RejectionQueryPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(
|
||||
user_id: UserId,
|
||||
deps: &Deps,
|
||||
) -> Result<Vec<RejectedMetric>, ApplicationError> {
|
||||
Ok(deps.rejections.find_recent_by_user(&user_id).await?)
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod list_daily_metrics;
|
||||
pub mod list_rejections;
|
||||
pub mod set_daily_metrics;
|
||||
|
||||
@@ -10,5 +10,6 @@ pub struct SubscribePushCommand {
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UnsubscribePushCommand {
|
||||
pub user_id: UserId,
|
||||
pub endpoint: String,
|
||||
}
|
||||
|
||||
19
crates/application/src/push/use_cases/list_subscriptions.rs
Normal file
19
crates/application/src/push/use_cases/list_subscriptions.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::PushSubscriptionQueryPort;
|
||||
use domain::push::PushSubscription;
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct Deps {
|
||||
pub push_query: Arc<dyn PushSubscriptionQueryPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(
|
||||
user_id: UserId,
|
||||
deps: &Deps,
|
||||
) -> Result<Vec<PushSubscription>, ApplicationError> {
|
||||
Ok(deps.push_query.find_by_user(&user_id).await?)
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod list_subscriptions;
|
||||
pub mod subscribe;
|
||||
pub mod unsubscribe;
|
||||
|
||||
@@ -14,16 +14,28 @@ pub struct Deps {
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(cmd: SubscribePushCommand, deps: &Deps) -> Result<(), ApplicationError> {
|
||||
if let Some(_existing) = deps.push_query.find_by_endpoint(&cmd.endpoint).await? {
|
||||
tracing::debug!(
|
||||
endpoint = cmd.endpoint,
|
||||
"push subscription already exists, updating"
|
||||
);
|
||||
}
|
||||
let known = deps.push_query.find_by_endpoint(&cmd.endpoint).await?;
|
||||
|
||||
let subscription = match known {
|
||||
Some(mut existing) => {
|
||||
if existing.user_id() != &cmd.user_id {
|
||||
tracing::info!(
|
||||
endpoint = cmd.endpoint,
|
||||
from = %existing.user_id(),
|
||||
to = %cmd.user_id,
|
||||
"a device already subscribed is moving to another account"
|
||||
);
|
||||
}
|
||||
|
||||
existing.renew(cmd.user_id, cmd.p256dh, cmd.auth);
|
||||
existing
|
||||
}
|
||||
None => PushSubscription::new(cmd.user_id, cmd.endpoint, cmd.p256dh, cmd.auth),
|
||||
};
|
||||
|
||||
let subscription = PushSubscription::new(cmd.user_id, cmd.endpoint, cmd.p256dh, cmd.auth);
|
||||
deps.push_command.save(&subscription).await?;
|
||||
|
||||
tracing::info!(user_id = %subscription.user_id(), "push subscription saved");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -12,7 +12,11 @@ pub struct Deps {
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(cmd: UnsubscribePushCommand, deps: &Deps) -> Result<(), ApplicationError> {
|
||||
deps.push_command.delete_by_endpoint(&cmd.endpoint).await?;
|
||||
tracing::info!(endpoint = cmd.endpoint, "push subscription removed");
|
||||
deps.push_command
|
||||
.delete_by_endpoint(&cmd.user_id, &cmd.endpoint)
|
||||
.await?;
|
||||
|
||||
tracing::info!(user_id = %cmd.user_id, endpoint = cmd.endpoint, "push subscription removed");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{Datelike, NaiveTime, Utc};
|
||||
use chrono::{Duration, Utc};
|
||||
|
||||
use domain::ports::{ReminderQueryPort, ReminderSenderPort, UserQueryPort};
|
||||
use domain::reminder::Reminder;
|
||||
use domain::ports::{ReminderCommandPort, ReminderQueryPort, ReminderSenderPort, UserQueryPort};
|
||||
use domain::reminder::{DueOccurrence, Reminder};
|
||||
use domain::user::Timezone;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
pub struct Deps {
|
||||
pub reminder_query: Arc<dyn ReminderQueryPort>,
|
||||
pub reminder_command: Arc<dyn ReminderCommandPort>,
|
||||
pub user_query: Arc<dyn UserQueryPort>,
|
||||
pub sender: Arc<dyn ReminderSenderPort>,
|
||||
pub grace: Duration,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
@@ -21,58 +24,74 @@ pub async fn execute(deps: &Deps) -> Result<u64, ApplicationError> {
|
||||
"checking enabled reminders"
|
||||
);
|
||||
|
||||
let now = Utc::now();
|
||||
let mut sent_count = 0u64;
|
||||
|
||||
// One user with no reachable device must not stop the sweep for everyone
|
||||
// scheduled behind them.
|
||||
for reminder in &reminders {
|
||||
if should_send(reminder, &deps.user_query).await? {
|
||||
tracing::info!(user_id = %reminder.user_id(), "sending reminder");
|
||||
match deps.sender.send_reminder(reminder.user_id()).await {
|
||||
Ok(()) => sent_count += 1,
|
||||
Err(error) => {
|
||||
tracing::warn!(user_id = %reminder.user_id(), %error, "could not send reminder")
|
||||
}
|
||||
}
|
||||
for reminder in reminders {
|
||||
let Some(timezone) = timezone_of(&reminder, deps).await else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(occurrence) = reminder.occurrence_reached(now, &timezone, deps.grace) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if send(reminder, occurrence, deps).await {
|
||||
sent_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(sent_count, "reminder processing completed");
|
||||
|
||||
Ok(sent_count)
|
||||
}
|
||||
|
||||
async fn should_send(
|
||||
reminder: &Reminder,
|
||||
user_query: &Arc<dyn UserQueryPort>,
|
||||
) -> Result<bool, ApplicationError> {
|
||||
let user = user_query.find_by_id(reminder.user_id()).await?;
|
||||
let user = match user {
|
||||
Some(u) => u,
|
||||
None => {
|
||||
async fn send(mut reminder: Reminder, occurrence: DueOccurrence, deps: &Deps) -> bool {
|
||||
tracing::info!(user_id = %reminder.user_id(), "sending reminder");
|
||||
|
||||
if let Err(error) = deps.sender.send_reminder(reminder.user_id()).await {
|
||||
tracing::warn!(user_id = %reminder.user_id(), %error, "could not send reminder");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
reminder.mark_sent(occurrence);
|
||||
|
||||
if let Err(error) = deps.reminder_command.save(&reminder).await {
|
||||
tracing::error!(
|
||||
reminder_id = %reminder.id(),
|
||||
%error,
|
||||
"sent a reminder but could not record it, so it may be sent again"
|
||||
);
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
async fn timezone_of(reminder: &Reminder, deps: &Deps) -> Option<Timezone> {
|
||||
let found = deps.user_query.find_by_id(reminder.user_id()).await;
|
||||
|
||||
let user = match found {
|
||||
Ok(Some(user)) => user,
|
||||
Ok(None) => {
|
||||
tracing::warn!(user_id = %reminder.user_id(), "reminder references nonexistent user");
|
||||
return Ok(false);
|
||||
|
||||
return None;
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
user_id = %reminder.user_id(),
|
||||
%error,
|
||||
"could not read the account behind a reminder, so the rest of the sweep goes on"
|
||||
);
|
||||
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let Some(timezone) = user.timezone() else {
|
||||
if user.timezone().is_none() {
|
||||
tracing::debug!(user_id = %reminder.user_id(), "user has no timezone set, skipping reminder");
|
||||
return Ok(false);
|
||||
};
|
||||
}
|
||||
|
||||
let now = Utc::now().with_timezone(&timezone.resolve());
|
||||
|
||||
let weekday = now.weekday();
|
||||
let scheduled_time = match reminder.schedule().time_for(weekday) {
|
||||
Some(time) => time,
|
||||
None => return Ok(false),
|
||||
};
|
||||
|
||||
let current_time = now.time();
|
||||
Ok(is_within_window(current_time, scheduled_time))
|
||||
}
|
||||
|
||||
fn is_within_window(current: NaiveTime, scheduled: NaiveTime) -> bool {
|
||||
let diff = current.signed_duration_since(scheduled);
|
||||
let minutes = diff.num_minutes();
|
||||
(0..5).contains(&minutes)
|
||||
user.timezone().copied()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{NaiveTime, Weekday};
|
||||
@@ -10,8 +11,9 @@ use domain::entry::{Date, Mood, MoodEntry};
|
||||
use domain::metric::{DailyMetric, MetricKind, MetricValue, Source};
|
||||
use domain::ports::{
|
||||
ActivityCommandPort, ActivityQueryPort, BackupReaderPort, CycleStartCommandPort,
|
||||
DailyMetricCommandPort, EntryDimensionPort, MediaStoragePort, MoodEntryCommandPort,
|
||||
ReminderCommandPort, RestorableActivity, RestorableContents, RestorableEntry, RestorableMetric,
|
||||
DailyMetricCommandPort, EntryDimensionPort, MediaOwnershipPort, MediaStoragePort,
|
||||
MoodEntryCommandPort, MoodEntryQueryPort, ReminderCommandPort, ReminderQueryPort,
|
||||
RestorableActivity, RestorableContents, RestorableEntry, RestorableMedia, RestorableMetric,
|
||||
RestorableReminder, UserPreferencesCommandPort, UserPreferencesQueryPort,
|
||||
};
|
||||
use domain::provider::ProviderName;
|
||||
@@ -19,6 +21,7 @@ use domain::reminder::{DaySchedule, Reminder};
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
use crate::import::already_here::AlreadyHere;
|
||||
use crate::restore::commands::RestoreBackupCommand;
|
||||
use crate::user::preferences::preferences_of;
|
||||
|
||||
@@ -32,6 +35,9 @@ const WEEK: [Weekday; 7] = [
|
||||
Weekday::Sun,
|
||||
];
|
||||
|
||||
const PHOTO_FALLBACK_TYPE: &str = "image/jpeg";
|
||||
const VOICE_MEMO_FALLBACK_TYPE: &str = "audio/webm";
|
||||
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub struct RestoreOutcome {
|
||||
pub entries: usize,
|
||||
@@ -40,21 +46,25 @@ pub struct RestoreOutcome {
|
||||
pub activities: usize,
|
||||
pub reminders: usize,
|
||||
pub media: usize,
|
||||
pub skipped: usize,
|
||||
pub unreadable: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct Deps {
|
||||
pub reader: Arc<dyn BackupReaderPort>,
|
||||
pub entry_command: Arc<dyn MoodEntryCommandPort>,
|
||||
pub entry_query: Arc<dyn MoodEntryQueryPort>,
|
||||
pub dimensions: Vec<Arc<dyn EntryDimensionPort>>,
|
||||
pub activity_command: Arc<dyn ActivityCommandPort>,
|
||||
pub activity_query: Arc<dyn ActivityQueryPort>,
|
||||
pub reminder_command: Arc<dyn ReminderCommandPort>,
|
||||
pub reminder_query: Arc<dyn ReminderQueryPort>,
|
||||
pub metrics: Arc<dyn DailyMetricCommandPort>,
|
||||
pub cycles: Arc<dyn CycleStartCommandPort>,
|
||||
pub preferences_command: Arc<dyn UserPreferencesCommandPort>,
|
||||
pub preferences_query: Arc<dyn UserPreferencesQueryPort>,
|
||||
pub media_storage: Arc<dyn MediaStoragePort>,
|
||||
pub media_ownership: Arc<dyn MediaOwnershipPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
@@ -67,7 +77,7 @@ pub async fn execute(
|
||||
let mut outcome = RestoreOutcome::default();
|
||||
|
||||
let activities = restore_activities(&user_id, &contents.activities, deps, &mut outcome).await?;
|
||||
let media = restore_media(&contents, deps, &mut outcome).await;
|
||||
let media = restore_media(&user_id, &contents, deps, &mut outcome).await;
|
||||
let renamed = Renamed { activities, media };
|
||||
restore_entries(&user_id, &contents.entries, &renamed, deps, &mut outcome).await?;
|
||||
restore_metrics(&user_id, &contents.metrics, deps, &mut outcome).await?;
|
||||
@@ -82,6 +92,7 @@ pub async fn execute(
|
||||
activities = outcome.activities,
|
||||
reminders = outcome.reminders,
|
||||
media = outcome.media,
|
||||
skipped = outcome.skipped,
|
||||
unreadable = outcome.unreadable.len(),
|
||||
"restored a backup"
|
||||
);
|
||||
@@ -124,8 +135,12 @@ async fn restore_activities(
|
||||
.and_then(|category| CategoryName::new(category).ok());
|
||||
|
||||
let mut activity = Activity::new(user_id.clone(), name, category);
|
||||
if held.archived {
|
||||
let _ = activity.archive();
|
||||
if held.archived
|
||||
&& let Err(error) = activity.archive()
|
||||
{
|
||||
outcome
|
||||
.unreadable
|
||||
.push(format!("activity {}: {error}", held.name));
|
||||
}
|
||||
|
||||
deps.activity_command.save(&activity).await?;
|
||||
@@ -143,6 +158,9 @@ async fn restore_entries(
|
||||
deps: &Deps,
|
||||
outcome: &mut RestoreOutcome,
|
||||
) -> Result<(), ApplicationError> {
|
||||
let existing = deps.entry_query.find_all_by_user(user_id).await?;
|
||||
let mut already_here = AlreadyHere::holding(&existing);
|
||||
|
||||
for held in backed_up {
|
||||
let Ok(logged_at) = chrono::DateTime::parse_from_rfc3339(&held.logged_at) else {
|
||||
outcome
|
||||
@@ -158,6 +176,12 @@ async fn restore_entries(
|
||||
continue;
|
||||
};
|
||||
|
||||
if already_here.holds(&logged_at, mood) {
|
||||
outcome.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
already_here.remember(&logged_at, mood);
|
||||
|
||||
let entry = MoodEntry::new(user_id.clone(), mood, logged_at);
|
||||
deps.entry_command.save(&entry).await?;
|
||||
|
||||
@@ -239,7 +263,14 @@ async fn restore_metrics(
|
||||
None => Source::Manual,
|
||||
Some(name) => match ProviderName::new(name) {
|
||||
Ok(provider) => Source::Provider(provider),
|
||||
Err(_) => Source::Manual,
|
||||
Err(_) => {
|
||||
outcome.unreadable.push(format!(
|
||||
"metric {} on {}: {name} is not a usable provider name, so it reads as manual",
|
||||
held.kind, held.date
|
||||
));
|
||||
|
||||
Source::Manual
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -276,14 +307,20 @@ async fn restore_reminders(
|
||||
deps: &Deps,
|
||||
outcome: &mut RestoreOutcome,
|
||||
) -> Result<(), ApplicationError> {
|
||||
for held in backed_up {
|
||||
let mut schedule = DaySchedule::new();
|
||||
let existing: Vec<DaySchedule> = deps
|
||||
.reminder_query
|
||||
.find_by_user(user_id)
|
||||
.await?
|
||||
.iter()
|
||||
.map(|reminder| reminder.schedule().clone())
|
||||
.collect();
|
||||
|
||||
for (day, time) in WEEK.iter().zip(&held.times) {
|
||||
let at = time
|
||||
.as_ref()
|
||||
.and_then(|time| NaiveTime::parse_from_str(time, "%H:%M").ok());
|
||||
schedule.set_time(*day, at);
|
||||
for held in backed_up {
|
||||
let schedule = rebuilt_schedule(held);
|
||||
|
||||
if existing.contains(&schedule) {
|
||||
outcome.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut reminder = Reminder::new(user_id.clone(), schedule);
|
||||
@@ -300,6 +337,19 @@ async fn restore_reminders(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rebuilt_schedule(held: &RestorableReminder) -> DaySchedule {
|
||||
let mut schedule = DaySchedule::new();
|
||||
|
||||
for (day, time) in WEEK.iter().zip(&held.times) {
|
||||
let at = time
|
||||
.as_ref()
|
||||
.and_then(|time| NaiveTime::parse_from_str(time, "%H:%M").ok());
|
||||
schedule.set_time(*day, at);
|
||||
}
|
||||
|
||||
schedule
|
||||
}
|
||||
|
||||
async fn restore_preferences(
|
||||
user_id: &UserId,
|
||||
tracks_cycle: bool,
|
||||
@@ -314,41 +364,81 @@ async fn restore_preferences(
|
||||
}
|
||||
|
||||
async fn restore_media(
|
||||
owner: &UserId,
|
||||
contents: &RestorableContents,
|
||||
deps: &Deps,
|
||||
outcome: &mut RestoreOutcome,
|
||||
) -> HashMap<String, String> {
|
||||
let mut renamed = HashMap::new();
|
||||
|
||||
for (was, bytes) in &contents.photos {
|
||||
let Ok(upload) = photo_upload(bytes) else {
|
||||
continue;
|
||||
for held in &contents.photos {
|
||||
let upload = match upload_of(held, PHOTO_FALLBACK_TYPE) {
|
||||
Ok(upload) => upload,
|
||||
Err(reason) => {
|
||||
outcome
|
||||
.unreadable
|
||||
.push(format!("photo {}: {reason}", held.id));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if let Ok(now) = deps.media_storage.store_photo(upload).await {
|
||||
renamed.insert(was.clone(), now.value().to_string());
|
||||
outcome.media += 1;
|
||||
match deps.media_storage.store_photo(upload).await {
|
||||
Ok(now) => {
|
||||
remember(owner, (&now).into(), deps, outcome).await;
|
||||
renamed.insert(held.id.clone(), now.value().to_string());
|
||||
outcome.media += 1;
|
||||
}
|
||||
Err(error) => outcome
|
||||
.unreadable
|
||||
.push(format!("photo {}: could not be stored: {error}", held.id)),
|
||||
}
|
||||
}
|
||||
|
||||
for (was, bytes) in &contents.voice_memos {
|
||||
let Ok(upload) = memo_upload(bytes) else {
|
||||
continue;
|
||||
for held in &contents.voice_memos {
|
||||
let upload = match upload_of(held, VOICE_MEMO_FALLBACK_TYPE) {
|
||||
Ok(upload) => upload,
|
||||
Err(reason) => {
|
||||
outcome
|
||||
.unreadable
|
||||
.push(format!("voice memo {}: {reason}", held.id));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if let Ok(now) = deps.media_storage.store_voice_memo(upload).await {
|
||||
renamed.insert(was.clone(), now.value().to_string());
|
||||
outcome.media += 1;
|
||||
match deps.media_storage.store_voice_memo(upload).await {
|
||||
Ok(now) => {
|
||||
remember(owner, (&now).into(), deps, outcome).await;
|
||||
renamed.insert(held.id.clone(), now.value().to_string());
|
||||
outcome.media += 1;
|
||||
}
|
||||
Err(error) => outcome.unreadable.push(format!(
|
||||
"voice memo {}: could not be stored: {error}",
|
||||
held.id
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
renamed
|
||||
}
|
||||
|
||||
fn photo_upload(bytes: &[u8]) -> Result<MediaUpload, domain::errors::DomainError> {
|
||||
MediaUpload::new(bytes.to_vec(), ContentType::new("image/jpeg")?)
|
||||
async fn remember(
|
||||
owner: &UserId,
|
||||
media: domain::attachment::MediaRef,
|
||||
deps: &Deps,
|
||||
outcome: &mut RestoreOutcome,
|
||||
) {
|
||||
if let Err(error) = deps.media_ownership.remember(owner, media).await {
|
||||
outcome
|
||||
.unreadable
|
||||
.push(format!("{media}: restored but not claimed: {error}"));
|
||||
}
|
||||
}
|
||||
|
||||
fn memo_upload(bytes: &[u8]) -> Result<MediaUpload, domain::errors::DomainError> {
|
||||
MediaUpload::new(bytes.to_vec(), ContentType::new("audio/webm")?)
|
||||
fn upload_of(held: &RestorableMedia, fallback: &str) -> Result<MediaUpload, String> {
|
||||
let content_type = match held.content_type.clone() {
|
||||
Some(content_type) => content_type,
|
||||
None => ContentType::new(fallback).map_err(|error| error.to_string())?,
|
||||
};
|
||||
|
||||
MediaUpload::new(held.data.clone(), content_type).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::{PasswordHasherPort, UserCommandPort, UserQueryPort};
|
||||
use domain::ports::{
|
||||
PasswordHasherPort, RefreshSessionCommandPort, UserCommandPort, UserQueryPort,
|
||||
};
|
||||
|
||||
use crate::errors::ApplicationError;
|
||||
|
||||
@@ -11,6 +13,7 @@ pub struct Deps {
|
||||
pub user_command: Arc<dyn UserCommandPort>,
|
||||
pub user_query: Arc<dyn UserQueryPort>,
|
||||
pub password_hasher: Arc<dyn PasswordHasherPort>,
|
||||
pub refresh_sessions: Arc<dyn RefreshSessionCommandPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all, fields(user_id = %cmd.user_id))]
|
||||
@@ -33,6 +36,9 @@ pub async fn execute(cmd: ChangePasswordCommand, deps: &Deps) -> Result<(), Appl
|
||||
user.update_password(new_hash);
|
||||
|
||||
deps.user_command.save(&user).await?;
|
||||
deps.refresh_sessions.revoke_all_for_user(user.id()).await?;
|
||||
|
||||
tracing::info!(user_id = %user.id(), "password changed and every session revoked");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,30 +1,25 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{CascadeDeletePort, EntryDimensionPort, MediaStoragePort, MoodEntryQueryPort};
|
||||
use domain::ports::{CascadeDeletePort, MediaOwnershipPort, MediaStoragePort};
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::entry::composition::EntryComposer;
|
||||
use crate::errors::ApplicationError;
|
||||
use crate::media::cleanup::delete_media_for;
|
||||
use crate::media::cleanup::delete_everything_owned_by;
|
||||
|
||||
pub struct Deps {
|
||||
pub entry_query: Arc<dyn MoodEntryQueryPort>,
|
||||
pub dimensions: Vec<Arc<dyn EntryDimensionPort>>,
|
||||
pub cascade: Arc<dyn CascadeDeletePort>,
|
||||
pub media_storage: Arc<dyn MediaStoragePort>,
|
||||
pub media_ownership: Arc<dyn MediaOwnershipPort>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(deps))]
|
||||
pub async fn execute(user_id: UserId, deps: &Deps) -> Result<(), ApplicationError> {
|
||||
let entries = deps.entry_query.find_by_user(&user_id, None, None).await?;
|
||||
|
||||
let composed = EntryComposer::new(deps.dimensions.clone())
|
||||
.compose(entries)
|
||||
.await?;
|
||||
delete_media_for(&composed, &deps.media_storage).await;
|
||||
let blobs =
|
||||
delete_everything_owned_by(&user_id, &deps.media_storage, &deps.media_ownership).await?;
|
||||
|
||||
deps.cascade.delete_all_user_data(&user_id).await?;
|
||||
|
||||
tracing::info!(%user_id, "all user data cleared");
|
||||
tracing::info!(%user_id, blobs, "all user data cleared");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2,21 +2,18 @@ use std::sync::Arc;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::{
|
||||
CascadeDeletePort, EntryDimensionPort, EventPublisherPort, MediaStoragePort,
|
||||
MoodEntryQueryPort, UserQueryPort,
|
||||
CascadeDeletePort, EventPublisherPort, MediaOwnershipPort, MediaStoragePort, UserQueryPort,
|
||||
};
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::entry::composition::EntryComposer;
|
||||
use crate::errors::ApplicationError;
|
||||
use crate::media::cleanup::delete_media_for;
|
||||
use crate::media::cleanup::delete_everything_owned_by;
|
||||
|
||||
pub struct Deps {
|
||||
pub user_query: Arc<dyn UserQueryPort>,
|
||||
pub entry_query: Arc<dyn MoodEntryQueryPort>,
|
||||
pub dimensions: Vec<Arc<dyn EntryDimensionPort>>,
|
||||
pub cascade: Arc<dyn CascadeDeletePort>,
|
||||
pub media_storage: Arc<dyn MediaStoragePort>,
|
||||
pub media_ownership: Arc<dyn MediaOwnershipPort>,
|
||||
pub events: Arc<dyn EventPublisherPort>,
|
||||
}
|
||||
|
||||
@@ -27,14 +24,12 @@ pub async fn execute(user_id: UserId, deps: &Deps) -> Result<(), ApplicationErro
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound("user not found".into()))?;
|
||||
|
||||
let entries = deps.entry_query.find_by_user(&user_id, None, None).await?;
|
||||
let composed = EntryComposer::new(deps.dimensions.clone())
|
||||
.compose(entries)
|
||||
.await?;
|
||||
delete_media_for(&composed, &deps.media_storage).await;
|
||||
let blobs =
|
||||
delete_everything_owned_by(&user_id, &deps.media_storage, &deps.media_ownership).await?;
|
||||
|
||||
deps.cascade.delete_user_account(&user_id).await?;
|
||||
|
||||
tracing::info!(%user_id, "user account deleted");
|
||||
tracing::info!(%user_id, blobs, "user account deleted");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
2
crates/application/tests/media.rs
Normal file
2
crates/application/tests/media.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
#[path = "media/custody_test.rs"]
|
||||
mod custody_test;
|
||||
138
crates/application/tests/media/custody_test.rs
Normal file
138
crates/application/tests/media/custody_test.rs
Normal 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:?}"
|
||||
);
|
||||
}
|
||||
2
crates/application/tests/push.rs
Normal file
2
crates/application/tests/push.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
#[path = "push/subscription_test.rs"]
|
||||
mod subscription_test;
|
||||
112
crates/application/tests/push/subscription_test.rs
Normal file
112
crates/application/tests/push/subscription_test.rs
Normal 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());
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user