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:
@@ -3,14 +3,14 @@ use chrono::{DateTime, Utc};
|
||||
use crate::provider::ProviderName;
|
||||
use crate::user::UserId;
|
||||
|
||||
use super::{ApiTokenId, TokenDigest, TokenScope};
|
||||
use super::{ApiTokenId, TokenDigest, TokenScope, TokenScopes};
|
||||
|
||||
pub struct ApiTokenData {
|
||||
pub id: ApiTokenId,
|
||||
pub user_id: UserId,
|
||||
pub name: ProviderName,
|
||||
pub digest: TokenDigest,
|
||||
pub scope: TokenScope,
|
||||
pub scopes: TokenScopes,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub last_used_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
@@ -21,19 +21,24 @@ pub struct ApiToken {
|
||||
user_id: UserId,
|
||||
name: ProviderName,
|
||||
digest: TokenDigest,
|
||||
scope: TokenScope,
|
||||
scopes: TokenScopes,
|
||||
created_at: DateTime<Utc>,
|
||||
last_used_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl ApiToken {
|
||||
pub fn new(user_id: UserId, name: ProviderName, digest: TokenDigest) -> Self {
|
||||
pub fn new(
|
||||
user_id: UserId,
|
||||
name: ProviderName,
|
||||
digest: TokenDigest,
|
||||
scopes: TokenScopes,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: ApiTokenId::generate(),
|
||||
user_id,
|
||||
name,
|
||||
digest,
|
||||
scope: TokenScope::WriteMetrics,
|
||||
scopes,
|
||||
created_at: Utc::now(),
|
||||
last_used_at: None,
|
||||
}
|
||||
@@ -45,7 +50,7 @@ impl ApiToken {
|
||||
user_id: data.user_id,
|
||||
name: data.name,
|
||||
digest: data.digest,
|
||||
scope: data.scope,
|
||||
scopes: data.scopes,
|
||||
created_at: data.created_at,
|
||||
last_used_at: data.last_used_at,
|
||||
}
|
||||
@@ -69,8 +74,12 @@ impl ApiToken {
|
||||
&self.digest
|
||||
}
|
||||
|
||||
pub fn scope(&self) -> TokenScope {
|
||||
self.scope
|
||||
pub fn scopes(&self) -> &TokenScopes {
|
||||
&self.scopes
|
||||
}
|
||||
|
||||
pub fn allows(&self, scope: TokenScope) -> bool {
|
||||
self.scopes.allows(scope)
|
||||
}
|
||||
|
||||
pub fn created_at(&self) -> &DateTime<Utc> {
|
||||
|
||||
@@ -3,9 +3,11 @@ mod api_token_id;
|
||||
mod minted_api_token;
|
||||
mod token_digest;
|
||||
mod token_scope;
|
||||
mod token_scopes;
|
||||
|
||||
pub use api_token::{ApiToken, ApiTokenData};
|
||||
pub use api_token_id::ApiTokenId;
|
||||
pub use minted_api_token::MintedApiToken;
|
||||
pub use token_digest::TokenDigest;
|
||||
pub use token_scope::TokenScope;
|
||||
pub use token_scopes::TokenScopes;
|
||||
|
||||
@@ -1,21 +1,59 @@
|
||||
const READ_JOURNAL: &str = "readJournal";
|
||||
const WRITE_JOURNAL: &str = "writeJournal";
|
||||
const WRITE_METRICS: &str = "writeMetrics";
|
||||
const READ_PROFILE: &str = "readProfile";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum TokenScope {
|
||||
ReadJournal,
|
||||
WriteJournal,
|
||||
WriteMetrics,
|
||||
ReadProfile,
|
||||
}
|
||||
|
||||
impl TokenScope {
|
||||
pub const ALL: [TokenScope; 4] = [
|
||||
Self::ReadJournal,
|
||||
Self::WriteJournal,
|
||||
Self::WriteMetrics,
|
||||
Self::ReadProfile,
|
||||
];
|
||||
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::ReadJournal => READ_JOURNAL,
|
||||
Self::WriteJournal => WRITE_JOURNAL,
|
||||
Self::WriteMetrics => WRITE_METRICS,
|
||||
Self::ReadProfile => READ_PROFILE,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn describes(&self) -> &'static str {
|
||||
match self {
|
||||
Self::ReadJournal => {
|
||||
"read entries, activities, the calendar, statistics, correlations, cycle records and daily metrics"
|
||||
}
|
||||
Self::WriteJournal => {
|
||||
"create, change and delete entries, activities, cycle records, reminders and media"
|
||||
}
|
||||
Self::WriteMetrics => "write daily metrics, attributed to this token's name",
|
||||
Self::ReadProfile => "read the account profile, its preferences and its connections",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_name(name: &str) -> Option<Self> {
|
||||
match name {
|
||||
READ_JOURNAL => Some(Self::ReadJournal),
|
||||
WRITE_JOURNAL => Some(Self::WriteJournal),
|
||||
WRITE_METRICS => Some(Self::WriteMetrics),
|
||||
READ_PROFILE => Some(Self::ReadProfile),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TokenScope {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.name())
|
||||
}
|
||||
}
|
||||
|
||||
62
crates/domain/src/api_token/token_scopes.rs
Normal file
62
crates/domain/src/api_token/token_scopes.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::errors::DomainError;
|
||||
|
||||
use super::TokenScope;
|
||||
|
||||
const SEPARATOR: char = ' ';
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TokenScopes(BTreeSet<TokenScope>);
|
||||
|
||||
impl TokenScopes {
|
||||
pub fn new(wanted: impl IntoIterator<Item = TokenScope>) -> Result<Self, DomainError> {
|
||||
let held: BTreeSet<TokenScope> = wanted.into_iter().collect();
|
||||
|
||||
if held.is_empty() {
|
||||
return Err(DomainError::InvalidInput(
|
||||
"a token that grants nothing is not worth minting: name at least one scope".into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Self(held))
|
||||
}
|
||||
|
||||
pub fn from_names<'a>(names: impl IntoIterator<Item = &'a str>) -> Result<Self, DomainError> {
|
||||
let mut wanted = BTreeSet::new();
|
||||
|
||||
for name in names {
|
||||
let scope = TokenScope::from_name(name).ok_or_else(|| {
|
||||
DomainError::InvalidInput(format!("{name} is not a scope this server grants"))
|
||||
})?;
|
||||
|
||||
wanted.insert(scope);
|
||||
}
|
||||
|
||||
Self::new(wanted)
|
||||
}
|
||||
|
||||
pub fn from_persistence(stored: &str) -> Option<Self> {
|
||||
let names: Vec<&str> = stored.split(SEPARATOR).filter(|s| !s.is_empty()).collect();
|
||||
|
||||
Self::from_names(names).ok()
|
||||
}
|
||||
|
||||
pub fn allows(&self, scope: TokenScope) -> bool {
|
||||
self.0.contains(&scope)
|
||||
}
|
||||
|
||||
pub fn names(&self) -> Vec<&'static str> {
|
||||
self.0.iter().map(TokenScope::name).collect()
|
||||
}
|
||||
|
||||
pub fn to_persistence(&self) -> String {
|
||||
self.names().join(&SEPARATOR.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TokenScopes {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.to_persistence())
|
||||
}
|
||||
}
|
||||
78
crates/domain/src/attachment/media_ref.rs
Normal file
78
crates/domain/src/attachment/media_ref.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
use super::{PhotoId, VoiceMemoId};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum MediaKind {
|
||||
Photo,
|
||||
VoiceMemo,
|
||||
}
|
||||
|
||||
impl MediaKind {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Photo => "photo",
|
||||
Self::VoiceMemo => "voice_memo",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_name(name: &str) -> Option<Self> {
|
||||
match name {
|
||||
"photo" => Some(Self::Photo),
|
||||
"voice_memo" => Some(Self::VoiceMemo),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct MediaRef {
|
||||
kind: MediaKind,
|
||||
id: uuid::Uuid,
|
||||
}
|
||||
|
||||
impl MediaRef {
|
||||
pub fn to_photo(&self) -> Option<PhotoId> {
|
||||
match self.kind {
|
||||
MediaKind::Photo => Some(PhotoId::from_uuid(self.id)),
|
||||
MediaKind::VoiceMemo => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_voice_memo(&self) -> Option<VoiceMemoId> {
|
||||
match self.kind {
|
||||
MediaKind::VoiceMemo => Some(VoiceMemoId::from_uuid(self.id)),
|
||||
MediaKind::Photo => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> MediaKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
pub fn id(&self) -> uuid::Uuid {
|
||||
self.id
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&PhotoId> for MediaRef {
|
||||
fn from(id: &PhotoId) -> Self {
|
||||
Self {
|
||||
kind: MediaKind::Photo,
|
||||
id: id.value(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&VoiceMemoId> for MediaRef {
|
||||
fn from(id: &VoiceMemoId) -> Self {
|
||||
Self {
|
||||
kind: MediaKind::VoiceMemo,
|
||||
id: id.value(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for MediaRef {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{} {}", self.kind.name(), self.id)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
mod content_type;
|
||||
mod media_ref;
|
||||
mod media_upload;
|
||||
mod photo_id;
|
||||
mod voice_memo_id;
|
||||
|
||||
pub use content_type::ContentType;
|
||||
pub use media_ref::{MediaKind, MediaRef};
|
||||
pub use media_upload::MediaUpload;
|
||||
pub use photo_id::PhotoId;
|
||||
pub use voice_memo_id::VoiceMemoId;
|
||||
|
||||
69
crates/domain/src/entry/entry_selection.rs
Normal file
69
crates/domain/src/entry/entry_selection.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use crate::activity::ActivityId;
|
||||
use crate::user::UserId;
|
||||
|
||||
use super::{DateRange, Mood};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EntrySelection {
|
||||
user_id: UserId,
|
||||
logged_within: Option<DateRange>,
|
||||
mood: Option<Mood>,
|
||||
tagged_with: Option<ActivityId>,
|
||||
changed_since: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl EntrySelection {
|
||||
pub fn everything_of(user_id: UserId) -> Self {
|
||||
Self {
|
||||
user_id,
|
||||
logged_within: None,
|
||||
mood: None,
|
||||
tagged_with: None,
|
||||
changed_since: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn logged_within(mut self, range: Option<DateRange>) -> Self {
|
||||
self.logged_within = range;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn of_mood(mut self, mood: Option<Mood>) -> Self {
|
||||
self.mood = mood;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn tagged_with(mut self, activity_id: Option<ActivityId>) -> Self {
|
||||
self.tagged_with = activity_id;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn changed_since(mut self, instant: Option<DateTime<Utc>>) -> Self {
|
||||
self.changed_since = instant;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl EntrySelection {
|
||||
pub fn user_id(&self) -> &UserId {
|
||||
&self.user_id
|
||||
}
|
||||
|
||||
pub fn range(&self) -> Option<&DateRange> {
|
||||
self.logged_within.as_ref()
|
||||
}
|
||||
|
||||
pub fn mood(&self) -> Option<Mood> {
|
||||
self.mood
|
||||
}
|
||||
|
||||
pub fn activity(&self) -> Option<&ActivityId> {
|
||||
self.tagged_with.as_ref()
|
||||
}
|
||||
|
||||
pub fn since(&self) -> Option<DateTime<Utc>> {
|
||||
self.changed_since
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,19 @@ mod date;
|
||||
mod date_range;
|
||||
mod date_span;
|
||||
mod day_mood;
|
||||
mod entry_selection;
|
||||
mod mood;
|
||||
mod mood_entry;
|
||||
mod mood_entry_id;
|
||||
mod pagination;
|
||||
|
||||
pub use content::Content;
|
||||
pub use date::Date;
|
||||
pub use date_range::DateRange;
|
||||
pub use date_span::DateSpan;
|
||||
pub use day_mood::DayMood;
|
||||
pub use entry_selection::EntrySelection;
|
||||
pub use mood::Mood;
|
||||
pub use mood_entry::{MoodEntry, MoodEntryData};
|
||||
pub use mood_entry_id::MoodEntryId;
|
||||
pub use pagination::{Page, Pagination};
|
||||
|
||||
@@ -11,10 +11,28 @@ pub enum Mood {
|
||||
Rad = 5,
|
||||
}
|
||||
|
||||
const AWFUL: &str = "Awful";
|
||||
const BAD: &str = "Bad";
|
||||
const MEH: &str = "Meh";
|
||||
const GOOD: &str = "Good";
|
||||
const RAD: &str = "Rad";
|
||||
|
||||
impl Mood {
|
||||
pub const ALL: [Mood; 5] = [Self::Awful, Self::Bad, Self::Meh, Self::Good, Self::Rad];
|
||||
|
||||
pub fn value(&self) -> u8 {
|
||||
*self as u8
|
||||
}
|
||||
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Awful => AWFUL,
|
||||
Self::Bad => BAD,
|
||||
Self::Meh => MEH,
|
||||
Self::Good => GOOD,
|
||||
Self::Rad => RAD,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<u8> for Mood {
|
||||
|
||||
86
crates/domain/src/entry/pagination.rs
Normal file
86
crates/domain/src/entry/pagination.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
use crate::errors::DomainError;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Pagination {
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
}
|
||||
|
||||
impl Pagination {
|
||||
pub fn new(limit: i64, offset: i64, most_per_page: i64) -> Result<Self, DomainError> {
|
||||
if limit < 1 {
|
||||
return Err(DomainError::InvalidInput(
|
||||
"a page of nothing is not a page: ask for at least one".into(),
|
||||
));
|
||||
}
|
||||
|
||||
if limit > most_per_page {
|
||||
return Err(DomainError::InvalidInput(format!(
|
||||
"this server serves at most {most_per_page} entries per page, and {limit} were asked for"
|
||||
)));
|
||||
}
|
||||
|
||||
if offset < 0 {
|
||||
return Err(DomainError::InvalidInput(
|
||||
"a page cannot start before the beginning".into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Self { limit, offset })
|
||||
}
|
||||
|
||||
pub fn limit(&self) -> i64 {
|
||||
self.limit
|
||||
}
|
||||
|
||||
pub fn offset(&self) -> i64 {
|
||||
self.offset
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Page<T> {
|
||||
items: Vec<T>,
|
||||
total: u64,
|
||||
at: Pagination,
|
||||
}
|
||||
|
||||
impl<T> Page<T> {
|
||||
pub fn new(items: Vec<T>, total: u64, at: Pagination) -> Self {
|
||||
Self { items, total, at }
|
||||
}
|
||||
|
||||
pub fn items(&self) -> &[T] {
|
||||
&self.items
|
||||
}
|
||||
|
||||
pub fn into_items(self) -> Vec<T> {
|
||||
self.items
|
||||
}
|
||||
|
||||
pub fn total(&self) -> u64 {
|
||||
self.total
|
||||
}
|
||||
|
||||
pub fn limit(&self) -> i64 {
|
||||
self.at.limit()
|
||||
}
|
||||
|
||||
pub fn offset(&self) -> i64 {
|
||||
self.at.offset()
|
||||
}
|
||||
|
||||
pub fn more_after_this(&self) -> bool {
|
||||
let seen = self.at.offset().saturating_add(self.items.len() as i64);
|
||||
|
||||
(seen as u64) < self.total
|
||||
}
|
||||
|
||||
pub fn map<U>(self, transform: impl FnMut(T) -> U) -> Page<U> {
|
||||
Page {
|
||||
items: self.items.into_iter().map(transform).collect(),
|
||||
total: self.total,
|
||||
at: self.at,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,11 +48,16 @@ macro_rules! bounded_metric {
|
||||
pub struct $name($inner);
|
||||
|
||||
impl $name {
|
||||
pub const MINIMUM: $inner = $min;
|
||||
pub const MAXIMUM: $inner = $max;
|
||||
|
||||
pub fn new(value: $inner) -> Result<Self, crate::errors::DomainError> {
|
||||
if !($min..=$max).contains(&value) {
|
||||
if !(Self::MINIMUM..=Self::MAXIMUM).contains(&value) {
|
||||
return Err(crate::errors::DomainError::InvalidInput(format!(
|
||||
concat!($label, " must be between {} and {}, got {}"),
|
||||
$min, $max, value
|
||||
Self::MINIMUM,
|
||||
Self::MAXIMUM,
|
||||
value
|
||||
)));
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,12 @@ const EXERCISE_MINUTES: &str = "exerciseMinutes";
|
||||
const SCREEN_TIME_MINUTES: &str = "screenTimeMinutes";
|
||||
const ALCOHOLIC_DRINKS: &str = "alcoholicDrinks";
|
||||
|
||||
const STEPS_UNIT: &str = "steps";
|
||||
const MINUTES_UNIT: &str = "minutes";
|
||||
const BEATS_PER_MINUTE_UNIT: &str = "bpm";
|
||||
const MILLISECONDS_UNIT: &str = "milliseconds";
|
||||
const DRINKS_UNIT: &str = "drinks";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum MetricKind {
|
||||
Steps,
|
||||
@@ -44,6 +50,47 @@ impl MetricKind {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unit(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Steps => STEPS_UNIT,
|
||||
Self::SleepMinutes | Self::AwakeMinutes => MINUTES_UNIT,
|
||||
Self::RestingHeartRate => BEATS_PER_MINUTE_UNIT,
|
||||
Self::Hrv => MILLISECONDS_UNIT,
|
||||
Self::ExerciseMinutes | Self::ScreenTimeMinutes => MINUTES_UNIT,
|
||||
Self::AlcoholicDrinks => DRINKS_UNIT,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bounds(&self) -> (i64, i64) {
|
||||
use crate::metric::{
|
||||
AlcoholicDrinks, AwakeMinutes, ExerciseMinutes, Hrv, RestingHeartRate,
|
||||
ScreenTimeMinutes, SleepMinutes, Steps,
|
||||
};
|
||||
|
||||
match self {
|
||||
Self::Steps => (Steps::MINIMUM.into(), Steps::MAXIMUM.into()),
|
||||
Self::SleepMinutes => (SleepMinutes::MINIMUM.into(), SleepMinutes::MAXIMUM.into()),
|
||||
Self::AwakeMinutes => (AwakeMinutes::MINIMUM.into(), AwakeMinutes::MAXIMUM.into()),
|
||||
Self::RestingHeartRate => (
|
||||
RestingHeartRate::MINIMUM.into(),
|
||||
RestingHeartRate::MAXIMUM.into(),
|
||||
),
|
||||
Self::Hrv => (Hrv::MINIMUM.into(), Hrv::MAXIMUM.into()),
|
||||
Self::ExerciseMinutes => (
|
||||
ExerciseMinutes::MINIMUM.into(),
|
||||
ExerciseMinutes::MAXIMUM.into(),
|
||||
),
|
||||
Self::ScreenTimeMinutes => (
|
||||
ScreenTimeMinutes::MINIMUM.into(),
|
||||
ScreenTimeMinutes::MAXIMUM.into(),
|
||||
),
|
||||
Self::AlcoholicDrinks => (
|
||||
AlcoholicDrinks::MINIMUM.into(),
|
||||
AlcoholicDrinks::MAXIMUM.into(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_name(name: &str) -> Option<Self> {
|
||||
match name {
|
||||
STEPS => Some(Self::Steps),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::activity::ActivityId;
|
||||
use crate::entry::{DateRange, Mood, MoodEntry, MoodEntryId};
|
||||
use crate::entry::{DateRange, EntrySelection, MoodEntry, MoodEntryId, Pagination};
|
||||
use crate::errors::DomainError;
|
||||
use crate::user::UserId;
|
||||
|
||||
@@ -27,28 +27,21 @@ pub trait MoodEntryCommandPort: Send + Sync {
|
||||
#[async_trait::async_trait]
|
||||
pub trait MoodEntryQueryPort: Send + Sync {
|
||||
async fn find_by_id(&self, id: &MoodEntryId) -> Result<Option<MoodEntry>, DomainError>;
|
||||
async fn find_by_user(
|
||||
async fn find_all_by_user(&self, user_id: &UserId) -> Result<Vec<MoodEntry>, DomainError>;
|
||||
|
||||
async fn select(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
selection: &EntrySelection,
|
||||
page: Pagination,
|
||||
) -> Result<Vec<MoodEntry>, DomainError>;
|
||||
|
||||
async fn count(&self, selection: &EntrySelection) -> Result<u64, DomainError>;
|
||||
|
||||
async fn find_by_date_range(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
range: &DateRange,
|
||||
) -> Result<Vec<MoodEntry>, DomainError>;
|
||||
|
||||
async fn find_by_mood(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
mood: Mood,
|
||||
) -> Result<Vec<MoodEntry>, DomainError>;
|
||||
|
||||
async fn find_by_activity(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
activity_id: &ActivityId,
|
||||
) -> Result<Vec<MoodEntry>, DomainError>;
|
||||
async fn count_tagged_with(&self, activity_id: &ActivityId) -> Result<u64, DomainError>;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::activity::Activity;
|
||||
use crate::attachment::ContentType;
|
||||
use crate::cycle::CycleStartRestore;
|
||||
use crate::dimension::ComposedEntry;
|
||||
use crate::errors::DomainError;
|
||||
@@ -9,6 +10,7 @@ use crate::user::UserPreferences;
|
||||
pub struct MediaBlob {
|
||||
pub id: String,
|
||||
pub data: Vec<u8>,
|
||||
pub content_type: ContentType,
|
||||
}
|
||||
|
||||
pub struct BackupMedia {
|
||||
|
||||
@@ -51,6 +51,11 @@ pub trait WeatherBacklogQueryPort: Send + Sync {
|
||||
&self,
|
||||
most: usize,
|
||||
) -> Result<Vec<UnwatchedPlace>, DomainError>;
|
||||
|
||||
async fn find_place_without_weather(
|
||||
&self,
|
||||
entry_id: &crate::entry::MoodEntryId,
|
||||
) -> Result<Option<UnwatchedPlace>, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -60,6 +65,11 @@ pub trait RecordingBackfillQueryPort: Send + Sync {
|
||||
most: usize,
|
||||
) -> Result<Vec<UnidentifiedSong>, DomainError>;
|
||||
|
||||
async fn find_song_without_a_recording(
|
||||
&self,
|
||||
entry_id: &crate::entry::MoodEntryId,
|
||||
) -> Result<Option<UnidentifiedSong>, DomainError>;
|
||||
|
||||
async fn record_identity(
|
||||
&self,
|
||||
entry_id: &crate::entry::MoodEntryId,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::attachment::{ContentType, MediaUpload, PhotoId, VoiceMemoId};
|
||||
use crate::attachment::{ContentType, MediaRef, MediaUpload, PhotoId, VoiceMemoId};
|
||||
use crate::errors::DomainError;
|
||||
use crate::user::UserId;
|
||||
|
||||
pub struct MediaFile {
|
||||
pub data: Vec<u8>,
|
||||
@@ -15,3 +16,12 @@ pub trait MediaStoragePort: Send + Sync {
|
||||
async fn delete_photo(&self, id: &PhotoId) -> Result<(), DomainError>;
|
||||
async fn delete_voice_memo(&self, id: &VoiceMemoId) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait MediaOwnershipPort: Send + Sync {
|
||||
async fn remember(&self, owner: &UserId, media: MediaRef) -> Result<(), DomainError>;
|
||||
async fn owner_of(&self, media: MediaRef) -> Result<Option<UserId>, DomainError>;
|
||||
async fn owned_by(&self, owner: &UserId) -> Result<Vec<MediaRef>, DomainError>;
|
||||
async fn forget(&self, media: MediaRef) -> Result<(), DomainError>;
|
||||
async fn forget_all_by_user(&self, owner: &UserId) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ pub use job::{
|
||||
JobQueueCommandPort, JobQueueQueryPort, RecordingBackfillQueryPort, UnidentifiedSong,
|
||||
UnwatchedPlace, WeatherBacklogQueryPort, WeatherLookupPort,
|
||||
};
|
||||
pub use media::{MediaFile, MediaStoragePort};
|
||||
pub use media::{MediaFile, MediaOwnershipPort, MediaStoragePort};
|
||||
pub use metric::{DailyMetricCommandPort, DailyMetricQueryPort};
|
||||
pub use music::{NowPlayingPort, RecordingLookupPort};
|
||||
pub use provider::{ProviderConnectionCommandPort, ProviderConnectionQueryPort};
|
||||
@@ -47,7 +47,7 @@ pub use push::{PushSubscriptionCommandPort, PushSubscriptionQueryPort};
|
||||
pub use rejection::{RejectionCommandPort, RejectionQueryPort};
|
||||
pub use reminder::{ReminderCommandPort, ReminderQueryPort, ReminderSenderPort};
|
||||
pub use restore::{
|
||||
BackupReaderPort, RestorableActivity, RestorableContents, RestorableEntry, RestorableMetric,
|
||||
RestorableReminder,
|
||||
BackupReaderPort, RestorableActivity, RestorableContents, RestorableEntry, RestorableMedia,
|
||||
RestorableMetric, RestorableReminder,
|
||||
};
|
||||
pub use user::{UserCommandPort, UserQueryPort};
|
||||
|
||||
@@ -6,7 +6,8 @@ use crate::user::UserId;
|
||||
pub trait PushSubscriptionCommandPort: Send + Sync {
|
||||
async fn save(&self, subscription: &PushSubscription) -> Result<(), DomainError>;
|
||||
async fn delete(&self, id: &PushSubscriptionId) -> Result<(), DomainError>;
|
||||
async fn delete_by_endpoint(&self, endpoint: &str) -> Result<(), DomainError>;
|
||||
async fn delete_by_endpoint(&self, user_id: &UserId, endpoint: &str)
|
||||
-> Result<(), DomainError>;
|
||||
async fn delete_all_by_user(&self, user_id: &UserId) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::attachment::ContentType;
|
||||
use crate::errors::DomainError;
|
||||
|
||||
pub struct RestorableEntry {
|
||||
@@ -32,8 +33,14 @@ pub struct RestorableContents {
|
||||
pub activities: Vec<RestorableActivity>,
|
||||
pub reminders: Vec<RestorableReminder>,
|
||||
pub tracks_cycle: bool,
|
||||
pub photos: Vec<(String, Vec<u8>)>,
|
||||
pub voice_memos: Vec<(String, Vec<u8>)>,
|
||||
pub photos: Vec<RestorableMedia>,
|
||||
pub voice_memos: Vec<RestorableMedia>,
|
||||
}
|
||||
|
||||
pub struct RestorableMedia {
|
||||
pub id: String,
|
||||
pub data: Vec<u8>,
|
||||
pub content_type: Option<ContentType>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
|
||||
@@ -45,6 +45,12 @@ impl PushSubscription {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn renew(&mut self, user_id: UserId, p256dh: String, auth: String) {
|
||||
self.user_id = user_id;
|
||||
self.p256dh = p256dh;
|
||||
self.auth = auth;
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &PushSubscriptionId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
62
crates/domain/src/reminder/due_occurrence.rs
Normal file
62
crates/domain/src/reminder/due_occurrence.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use chrono::{DateTime, Datelike, Duration, NaiveDate, NaiveTime, TimeDelta, Utc};
|
||||
|
||||
use crate::user::Timezone;
|
||||
|
||||
use super::DaySchedule;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct DueOccurrence {
|
||||
scheduled_for: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl DueOccurrence {
|
||||
pub fn scheduled_for(&self) -> DateTime<Utc> {
|
||||
self.scheduled_for
|
||||
}
|
||||
}
|
||||
|
||||
pub fn occurrence_reached(
|
||||
schedule: &DaySchedule,
|
||||
last_sent_at: Option<DateTime<Utc>>,
|
||||
now: DateTime<Utc>,
|
||||
timezone: &Timezone,
|
||||
grace: Duration,
|
||||
) -> Option<DueOccurrence> {
|
||||
let local_today = now.with_timezone(&timezone.resolve()).date_naive();
|
||||
|
||||
[local_today, local_today.pred_opt()?]
|
||||
.into_iter()
|
||||
.filter_map(|local_date| scheduled_instant(schedule, local_date, timezone))
|
||||
.filter(|scheduled| reached_but_not_stale(*scheduled, now, grace))
|
||||
.filter(|scheduled| not_already_sent(*scheduled, last_sent_at))
|
||||
.max()
|
||||
.map(|scheduled_for| DueOccurrence { scheduled_for })
|
||||
}
|
||||
|
||||
fn scheduled_instant(
|
||||
schedule: &DaySchedule,
|
||||
local_date: NaiveDate,
|
||||
timezone: &Timezone,
|
||||
) -> Option<DateTime<Utc>> {
|
||||
let time = schedule.time_for(local_date.weekday())?;
|
||||
|
||||
placed_in(local_date, time, timezone)
|
||||
}
|
||||
|
||||
fn placed_in(local_date: NaiveDate, time: NaiveTime, timezone: &Timezone) -> Option<DateTime<Utc>> {
|
||||
local_date
|
||||
.and_time(time)
|
||||
.and_local_timezone(timezone.resolve())
|
||||
.earliest()
|
||||
.map(|placed| placed.with_timezone(&Utc))
|
||||
}
|
||||
|
||||
fn reached_but_not_stale(scheduled: DateTime<Utc>, now: DateTime<Utc>, grace: Duration) -> bool {
|
||||
let since = now.signed_duration_since(scheduled);
|
||||
|
||||
since >= TimeDelta::zero() && since < grace
|
||||
}
|
||||
|
||||
fn not_already_sent(scheduled: DateTime<Utc>, last_sent_at: Option<DateTime<Utc>>) -> bool {
|
||||
last_sent_at.is_none_or(|sent| sent < scheduled)
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
mod day_schedule;
|
||||
mod due_occurrence;
|
||||
mod reminder;
|
||||
mod reminder_id;
|
||||
|
||||
pub use day_schedule::DaySchedule;
|
||||
pub use due_occurrence::DueOccurrence;
|
||||
pub use reminder::Reminder;
|
||||
pub use reminder_id::ReminderId;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
|
||||
use crate::user::UserId;
|
||||
use crate::user::{Timezone, UserId};
|
||||
|
||||
use super::due_occurrence::{DueOccurrence, occurrence_reached};
|
||||
use super::{DaySchedule, ReminderId};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -11,6 +12,7 @@ pub struct Reminder {
|
||||
schedule: DaySchedule,
|
||||
enabled: bool,
|
||||
created_at: DateTime<Utc>,
|
||||
last_sent_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl Reminder {
|
||||
@@ -21,6 +23,7 @@ impl Reminder {
|
||||
schedule,
|
||||
enabled: true,
|
||||
created_at: Utc::now(),
|
||||
last_sent_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +33,7 @@ impl Reminder {
|
||||
schedule: DaySchedule,
|
||||
enabled: bool,
|
||||
created_at: DateTime<Utc>,
|
||||
last_sent_at: Option<DateTime<Utc>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
@@ -37,6 +41,7 @@ impl Reminder {
|
||||
schedule,
|
||||
enabled,
|
||||
created_at,
|
||||
last_sent_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,6 +66,23 @@ impl Reminder {
|
||||
pub fn created_at(&self) -> &DateTime<Utc> {
|
||||
&self.created_at
|
||||
}
|
||||
|
||||
pub fn last_sent_at(&self) -> Option<DateTime<Utc>> {
|
||||
self.last_sent_at
|
||||
}
|
||||
|
||||
pub fn occurrence_reached(
|
||||
&self,
|
||||
now: DateTime<Utc>,
|
||||
timezone: &Timezone,
|
||||
grace: Duration,
|
||||
) -> Option<DueOccurrence> {
|
||||
if !self.enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
occurrence_reached(&self.schedule, self.last_sent_at, now, timezone, grace)
|
||||
}
|
||||
}
|
||||
|
||||
impl Reminder {
|
||||
@@ -75,4 +97,8 @@ impl Reminder {
|
||||
pub fn disable(&mut self) {
|
||||
self.enabled = false;
|
||||
}
|
||||
|
||||
pub fn mark_sent(&mut self, occurrence: DueOccurrence) {
|
||||
self.last_sent_at = Some(occurrence.scheduled_for());
|
||||
}
|
||||
}
|
||||
|
||||
54
crates/domain/src/testing/fakes/activity_dimension.rs
Normal file
54
crates/domain/src/testing/fakes/activity_dimension.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::dimension::{DimensionKind, DimensionValue, lookup};
|
||||
use crate::entry::MoodEntryId;
|
||||
use crate::errors::DomainError;
|
||||
|
||||
use super::store::InMemoryStore;
|
||||
|
||||
pub struct InMemoryActivityDimension {
|
||||
store: Arc<InMemoryStore>,
|
||||
}
|
||||
|
||||
impl InMemoryActivityDimension {
|
||||
pub fn sharing(store: Arc<InMemoryStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::ports::EntryDimensionPort for InMemoryActivityDimension {
|
||||
async fn load(
|
||||
&self,
|
||||
entry_ids: &[MoodEntryId],
|
||||
) -> Result<HashMap<MoodEntryId, DimensionValue>, DomainError> {
|
||||
let tagged = self.store.entry_activities.read().unwrap();
|
||||
|
||||
Ok(entry_ids
|
||||
.iter()
|
||||
.filter_map(|id| tagged.get(id).map(|ids| (id.clone(), ids.clone())))
|
||||
.filter(|(_, ids)| !ids.is_empty())
|
||||
.map(|(id, ids)| (id, DimensionValue::activities(ids)))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn save(
|
||||
&self,
|
||||
entry_id: &MoodEntryId,
|
||||
values: &[DimensionValue],
|
||||
) -> Result<(), DomainError> {
|
||||
let mut tagged = self.store.entry_activities.write().unwrap();
|
||||
|
||||
let names_activities = values
|
||||
.iter()
|
||||
.any(|value| value.kind() == DimensionKind::Activities);
|
||||
|
||||
match names_activities {
|
||||
true => tagged.insert(entry_id.clone(), lookup::activities_in(values).to_vec()),
|
||||
false => tagged.remove(entry_id),
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,11 @@ use crate::attachment::{ContentType, MediaUpload, PhotoId, VoiceMemoId};
|
||||
use crate::errors::DomainError;
|
||||
use crate::ports::MediaFile;
|
||||
|
||||
type Blob = (Vec<u8>, ContentType);
|
||||
|
||||
pub struct FakeMediaStorage {
|
||||
photos: RwLock<HashMap<PhotoId, Vec<u8>>>,
|
||||
voice_memos: RwLock<HashMap<VoiceMemoId, Vec<u8>>>,
|
||||
photos: RwLock<HashMap<PhotoId, Blob>>,
|
||||
voice_memos: RwLock<HashMap<VoiceMemoId, Blob>>,
|
||||
deletions_attempted: RwLock<Vec<String>>,
|
||||
refusing_to_delete: bool,
|
||||
}
|
||||
@@ -30,17 +32,49 @@ impl FakeMediaStorage {
|
||||
}
|
||||
|
||||
pub fn put_photo(&self, id: &PhotoId, bytes: &[u8]) {
|
||||
self.put_photo_as(
|
||||
id,
|
||||
bytes,
|
||||
ContentType::from_persistence("image/jpeg".into()),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn put_photo_as(&self, id: &PhotoId, bytes: &[u8], content_type: ContentType) {
|
||||
self.photos
|
||||
.write()
|
||||
.unwrap()
|
||||
.insert(id.clone(), bytes.to_vec());
|
||||
.insert(id.clone(), (bytes.to_vec(), content_type));
|
||||
}
|
||||
|
||||
pub fn put_voice_memo(&self, id: &VoiceMemoId, bytes: &[u8]) {
|
||||
self.put_voice_memo_as(
|
||||
id,
|
||||
bytes,
|
||||
ContentType::from_persistence("audio/webm".into()),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn put_voice_memo_as(&self, id: &VoiceMemoId, bytes: &[u8], content_type: ContentType) {
|
||||
self.voice_memos
|
||||
.write()
|
||||
.unwrap()
|
||||
.insert(id.clone(), bytes.to_vec());
|
||||
.insert(id.clone(), (bytes.to_vec(), content_type));
|
||||
}
|
||||
|
||||
pub fn type_of_photo(&self, id: &PhotoId) -> Option<ContentType> {
|
||||
self.photos
|
||||
.read()
|
||||
.unwrap()
|
||||
.get(id)
|
||||
.map(|(_, content_type)| content_type.clone())
|
||||
}
|
||||
|
||||
pub fn type_of_voice_memo(&self, id: &VoiceMemoId) -> Option<ContentType> {
|
||||
self.voice_memos
|
||||
.read()
|
||||
.unwrap()
|
||||
.get(id)
|
||||
.map(|(_, content_type)| content_type.clone())
|
||||
}
|
||||
|
||||
pub fn holds_photo(&self, id: &PhotoId) -> bool {
|
||||
@@ -70,23 +104,28 @@ impl Default for FakeMediaStorage {
|
||||
impl crate::ports::MediaStoragePort for FakeMediaStorage {
|
||||
async fn store_photo(&self, upload: MediaUpload) -> Result<PhotoId, DomainError> {
|
||||
let id = PhotoId::generate();
|
||||
self.put_photo(&id, upload.data());
|
||||
self.put_photo_as(&id, upload.data(), upload.content_type().clone());
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
async fn store_voice_memo(&self, upload: MediaUpload) -> Result<VoiceMemoId, DomainError> {
|
||||
let id = VoiceMemoId::generate();
|
||||
self.put_voice_memo(&id, upload.data());
|
||||
self.put_voice_memo_as(&id, upload.data(), upload.content_type().clone());
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
async fn get_photo(&self, id: &PhotoId) -> Result<Option<MediaFile>, DomainError> {
|
||||
Ok(self.photos.read().unwrap().get(id).map(|bytes| MediaFile {
|
||||
data: bytes.clone(),
|
||||
content_type: ContentType::from_persistence("image/jpeg".into()),
|
||||
}))
|
||||
Ok(self
|
||||
.photos
|
||||
.read()
|
||||
.unwrap()
|
||||
.get(id)
|
||||
.map(|(data, content_type)| MediaFile {
|
||||
data: data.clone(),
|
||||
content_type: content_type.clone(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn get_voice_memo(&self, id: &VoiceMemoId) -> Result<Option<MediaFile>, DomainError> {
|
||||
@@ -95,9 +134,9 @@ impl crate::ports::MediaStoragePort for FakeMediaStorage {
|
||||
.read()
|
||||
.unwrap()
|
||||
.get(id)
|
||||
.map(|bytes| MediaFile {
|
||||
data: bytes.clone(),
|
||||
content_type: ContentType::from_persistence("audio/webm".into()),
|
||||
.map(|(data, content_type)| MediaFile {
|
||||
data: data.clone(),
|
||||
content_type: content_type.clone(),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod activity_dimension;
|
||||
mod api_token_secret;
|
||||
mod cipher;
|
||||
mod dimension_store;
|
||||
@@ -13,6 +14,7 @@ mod store_cycle;
|
||||
mod store_entry;
|
||||
mod store_infra;
|
||||
mod store_job;
|
||||
mod store_media;
|
||||
mod store_metric;
|
||||
mod store_provider;
|
||||
mod store_rejection;
|
||||
@@ -20,6 +22,7 @@ mod store_reminder;
|
||||
mod store_user;
|
||||
mod weather;
|
||||
|
||||
pub use activity_dimension::InMemoryActivityDimension;
|
||||
pub use api_token_secret::FakeApiTokenSecret;
|
||||
pub use cipher::FakeCredentialCipher;
|
||||
pub use dimension_store::InMemoryDimensionStore;
|
||||
|
||||
@@ -45,6 +45,7 @@ pub struct InMemoryStore {
|
||||
pub(super) dependent_dimensions: RwLock<Vec<std::sync::Arc<InMemoryDimensionStore>>>,
|
||||
pub(super) events: RwLock<Vec<EventEnvelope>>,
|
||||
pub(super) sent_reminders: RwLock<Vec<UserId>>,
|
||||
pub(super) media_owners: RwLock<HashMap<crate::attachment::MediaRef, UserId>>,
|
||||
}
|
||||
|
||||
impl InMemoryStore {
|
||||
@@ -71,6 +72,7 @@ impl InMemoryStore {
|
||||
dependent_dimensions: RwLock::new(Vec::new()),
|
||||
events: RwLock::new(Vec::new()),
|
||||
sent_reminders: RwLock::new(Vec::new()),
|
||||
media_owners: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::activity::ActivityId;
|
||||
use crate::entry::{DateRange, Mood, MoodEntry, MoodEntryId};
|
||||
use crate::entry::{DateRange, EntrySelection, MoodEntry, MoodEntryId, Pagination};
|
||||
use crate::errors::DomainError;
|
||||
use crate::user::UserId;
|
||||
|
||||
@@ -84,26 +84,28 @@ impl crate::ports::MoodEntryQueryPort for InMemoryStore {
|
||||
Ok(self.entries.read().unwrap().get(id).cloned())
|
||||
}
|
||||
|
||||
async fn find_by_user(
|
||||
async fn find_all_by_user(&self, user_id: &UserId) -> Result<Vec<MoodEntry>, DomainError> {
|
||||
Ok(self.newest_first(|entry| entry.user_id() == user_id))
|
||||
}
|
||||
|
||||
async fn select(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
selection: &EntrySelection,
|
||||
page: Pagination,
|
||||
) -> Result<Vec<MoodEntry>, DomainError> {
|
||||
let mut entries: Vec<_> = self
|
||||
.entries
|
||||
.read()
|
||||
.unwrap()
|
||||
.values()
|
||||
.filter(|e| e.user_id() == user_id)
|
||||
.cloned()
|
||||
.collect();
|
||||
let matching = self.newest_first(|entry| self.matches(entry, selection));
|
||||
|
||||
let offset = offset.unwrap_or(0) as usize;
|
||||
let limit = limit.unwrap_or(i64::MAX) as usize;
|
||||
entries = entries.into_iter().skip(offset).take(limit).collect();
|
||||
Ok(matching
|
||||
.into_iter()
|
||||
.skip(page.offset() as usize)
|
||||
.take(page.limit() as usize)
|
||||
.collect())
|
||||
}
|
||||
|
||||
Ok(entries)
|
||||
async fn count(&self, selection: &EntrySelection) -> Result<u64, DomainError> {
|
||||
Ok(self
|
||||
.newest_first(|entry| self.matches(entry, selection))
|
||||
.len() as u64)
|
||||
}
|
||||
|
||||
async fn find_by_date_range(
|
||||
@@ -125,41 +127,69 @@ impl crate::ports::MoodEntryQueryPort for InMemoryStore {
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn find_by_mood(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
mood: Mood,
|
||||
) -> Result<Vec<MoodEntry>, DomainError> {
|
||||
Ok(self
|
||||
.entries
|
||||
async fn count_tagged_with(&self, activity_id: &ActivityId) -> Result<u64, DomainError> {
|
||||
let tagged = self
|
||||
.entry_activities
|
||||
.read()
|
||||
.unwrap()
|
||||
.values()
|
||||
.filter(|e| e.user_id() == user_id && e.mood() == mood)
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
.filter(|ids| ids.contains(activity_id))
|
||||
.count();
|
||||
|
||||
async fn find_by_activity(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
activity_id: &ActivityId,
|
||||
) -> Result<Vec<MoodEntry>, DomainError> {
|
||||
Ok(self
|
||||
.entries
|
||||
.read()
|
||||
.unwrap()
|
||||
.values()
|
||||
.filter(|e| {
|
||||
e.user_id() == user_id
|
||||
&& self
|
||||
.entry_activities
|
||||
.read()
|
||||
.unwrap()
|
||||
.get(e.id())
|
||||
.is_some_and(|ids| ids.contains(activity_id))
|
||||
})
|
||||
.cloned()
|
||||
.collect())
|
||||
Ok(tagged as u64)
|
||||
}
|
||||
}
|
||||
|
||||
impl InMemoryStore {
|
||||
fn newest_first(&self, wanted: impl Fn(&MoodEntry) -> bool) -> Vec<MoodEntry> {
|
||||
let mut found: Vec<MoodEntry> = self
|
||||
.entries
|
||||
.read()
|
||||
.unwrap()
|
||||
.values()
|
||||
.filter(|entry| wanted(entry))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
found.sort_by(|left, right| right.logged_at().cmp(left.logged_at()));
|
||||
|
||||
found
|
||||
}
|
||||
|
||||
fn matches(&self, entry: &MoodEntry, selection: &EntrySelection) -> bool {
|
||||
if entry.user_id() != selection.user_id() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(range) = selection.range()
|
||||
&& (entry.logged_at() < range.start() || entry.logged_at() > range.end())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(mood) = selection.mood()
|
||||
&& entry.mood() != mood
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(activity_id) = selection.activity()
|
||||
&& !self
|
||||
.entry_activities
|
||||
.read()
|
||||
.unwrap()
|
||||
.get(entry.id())
|
||||
.is_some_and(|ids| ids.contains(activity_id))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(since) = selection.since()
|
||||
&& *entry.updated_at() <= since
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,45 +49,54 @@ impl crate::ports::MediaStoragePort for InMemoryStore {
|
||||
}
|
||||
}
|
||||
|
||||
impl InMemoryStore {
|
||||
fn forget_what_the_user_logged(&self, user_id: &UserId) {
|
||||
self.entries
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|_, entry| entry.user_id() != user_id);
|
||||
self.activities
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|_, activity| activity.user_id() != user_id);
|
||||
self.reminders
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|_, reminder| reminder.user_id() != user_id);
|
||||
self.daily_metrics
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|metric| metric.user_id() != user_id);
|
||||
self.cycle_starts
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|(held, _)| held != user_id);
|
||||
self.rejections
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|rejected| rejected.user_id() != user_id);
|
||||
self.media_owners
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|_, owner| owner != user_id);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::ports::CascadeDeletePort for InMemoryStore {
|
||||
async fn delete_all_user_data(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||
self.cascade_to_dimensions(&self.entry_ids_of(user_id));
|
||||
|
||||
self.entries
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|_, e| e.user_id() != user_id);
|
||||
self.activities
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|_, a| a.user_id() != user_id);
|
||||
self.reminders
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|_, r| r.user_id() != user_id);
|
||||
self.daily_metrics
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|m| m.user_id() != user_id);
|
||||
self.forget_what_the_user_logged(user_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_user_account(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||
self.cascade_to_dimensions(&self.entry_ids_of(user_id));
|
||||
|
||||
self.entries
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|_, e| e.user_id() != user_id);
|
||||
self.activities
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|_, a| a.user_id() != user_id);
|
||||
self.reminders
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|_, r| r.user_id() != user_id);
|
||||
self.forget_what_the_user_logged(user_id);
|
||||
|
||||
self.refresh_sessions
|
||||
.write()
|
||||
.unwrap()
|
||||
@@ -96,11 +105,16 @@ impl crate::ports::CascadeDeletePort for InMemoryStore {
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|_, s| s.user_id() != user_id);
|
||||
self.daily_metrics
|
||||
self.provider_connections
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|m| m.user_id() != user_id);
|
||||
.retain(|c| c.user_id() != user_id);
|
||||
self.api_tokens
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|t| t.user_id() != user_id);
|
||||
self.users.write().unwrap().remove(user_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -147,10 +161,13 @@ impl InMemoryStore {
|
||||
#[async_trait::async_trait]
|
||||
impl crate::ports::PushSubscriptionCommandPort for InMemoryStore {
|
||||
async fn save(&self, subscription: &PushSubscription) -> Result<(), DomainError> {
|
||||
self.push_subscriptions
|
||||
.write()
|
||||
.unwrap()
|
||||
.insert(subscription.id().clone(), subscription.clone());
|
||||
let mut held = self.push_subscriptions.write().unwrap();
|
||||
|
||||
held.retain(|id, existing| {
|
||||
id == subscription.id() || existing.endpoint() != subscription.endpoint()
|
||||
});
|
||||
held.insert(subscription.id().clone(), subscription.clone());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -159,11 +176,15 @@ impl crate::ports::PushSubscriptionCommandPort for InMemoryStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_by_endpoint(&self, endpoint: &str) -> Result<(), DomainError> {
|
||||
async fn delete_by_endpoint(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
endpoint: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
self.push_subscriptions
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|_, s| s.endpoint() != endpoint);
|
||||
.retain(|_, s| s.user_id() != user_id || s.endpoint() != endpoint);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -172,6 +172,15 @@ impl crate::ports::RecordingBackfillQueryPort for InMemoryStore {
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn find_song_without_a_recording(
|
||||
&self,
|
||||
entry_id: &crate::entry::MoodEntryId,
|
||||
) -> Result<Option<UnidentifiedSong>, DomainError> {
|
||||
let waiting = self.find_songs_without_a_recording(usize::MAX).await?;
|
||||
|
||||
Ok(waiting.into_iter().find(|song| &song.entry_id == entry_id))
|
||||
}
|
||||
|
||||
async fn record_identity(
|
||||
&self,
|
||||
entry_id: &crate::entry::MoodEntryId,
|
||||
@@ -241,4 +250,15 @@ impl crate::ports::WeatherBacklogQueryPort for InMemoryStore {
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn find_place_without_weather(
|
||||
&self,
|
||||
entry_id: &crate::entry::MoodEntryId,
|
||||
) -> Result<Option<crate::ports::UnwatchedPlace>, DomainError> {
|
||||
let waiting = self.find_places_without_weather(usize::MAX).await?;
|
||||
|
||||
Ok(waiting
|
||||
.into_iter()
|
||||
.find(|place| &place.entry_id == entry_id))
|
||||
}
|
||||
}
|
||||
|
||||
47
crates/domain/src/testing/fakes/store_media.rs
Normal file
47
crates/domain/src/testing/fakes/store_media.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use crate::attachment::MediaRef;
|
||||
use crate::errors::DomainError;
|
||||
use crate::user::UserId;
|
||||
|
||||
use super::store::InMemoryStore;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::ports::MediaOwnershipPort for InMemoryStore {
|
||||
async fn remember(&self, owner: &UserId, media: MediaRef) -> Result<(), DomainError> {
|
||||
self.media_owners
|
||||
.write()
|
||||
.unwrap()
|
||||
.insert(media, owner.clone());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn owner_of(&self, media: MediaRef) -> Result<Option<UserId>, DomainError> {
|
||||
Ok(self.media_owners.read().unwrap().get(&media).cloned())
|
||||
}
|
||||
|
||||
async fn owned_by(&self, owner: &UserId) -> Result<Vec<MediaRef>, DomainError> {
|
||||
Ok(self
|
||||
.media_owners
|
||||
.read()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|(_, held)| *held == owner)
|
||||
.map(|(media, _)| *media)
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn forget(&self, media: MediaRef) -> Result<(), DomainError> {
|
||||
self.media_owners.write().unwrap().remove(&media);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn forget_all_by_user(&self, owner: &UserId) -> Result<(), DomainError> {
|
||||
self.media_owners
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|_, held| held != owner);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,6 @@ mod fakes;
|
||||
pub use factories::*;
|
||||
pub use fakes::{
|
||||
FakeApiTokenSecret, FakeCredentialCipher, FakeMediaStorage, FakeNowPlaying, FakePasswordHasher,
|
||||
FakeRecordingLookup, FakeWeatherLookup, InMemoryDimensionStore, InMemoryStore, OneTokenStore,
|
||||
RefusingApiTokenStore, RefusingRejectionTrace,
|
||||
FakeRecordingLookup, FakeWeatherLookup, InMemoryActivityDimension, InMemoryDimensionStore,
|
||||
InMemoryStore, OneTokenStore, RefusingApiTokenStore, RefusingRejectionTrace,
|
||||
};
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
use domain::api_token::{ApiToken, MintedApiToken, TokenDigest, TokenScope};
|
||||
use domain::api_token::{ApiToken, MintedApiToken, TokenDigest, TokenScope, TokenScopes};
|
||||
use domain::provider::ProviderName;
|
||||
use domain::user::UserId;
|
||||
|
||||
fn a_token() -> ApiToken {
|
||||
token_granting([TokenScope::WriteMetrics])
|
||||
}
|
||||
|
||||
fn token_granting(scopes: impl IntoIterator<Item = TokenScope>) -> ApiToken {
|
||||
ApiToken::new(
|
||||
UserId::generate(),
|
||||
ProviderName::new("iphone-shortcuts").unwrap(),
|
||||
TokenDigest::from_persistence("digest-of-the-secret".into()),
|
||||
TokenScopes::new(scopes).unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,7 +20,7 @@ fn a_fresh_token_has_never_been_used() {
|
||||
let token = a_token();
|
||||
|
||||
assert!(token.last_used_at().is_none());
|
||||
assert_eq!(token.scope(), TokenScope::WriteMetrics);
|
||||
assert!(token.allows(TokenScope::WriteMetrics));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -36,13 +41,58 @@ fn the_name_a_token_carries_is_the_provider_its_writes_are_attributed_to() {
|
||||
|
||||
#[test]
|
||||
fn every_scope_survives_a_round_trip_through_its_name() {
|
||||
assert_eq!(
|
||||
TokenScope::from_name(TokenScope::WriteMetrics.name()),
|
||||
Some(TokenScope::WriteMetrics)
|
||||
);
|
||||
for scope in TokenScope::ALL {
|
||||
assert_eq!(TokenScope::from_name(scope.name()), Some(scope));
|
||||
}
|
||||
|
||||
assert_eq!(TokenScope::from_name("read-everything"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_token_grants_only_what_it_was_minted_for() {
|
||||
let token = token_granting([TokenScope::ReadJournal]);
|
||||
|
||||
assert!(token.allows(TokenScope::ReadJournal));
|
||||
assert!(!token.allows(TokenScope::WriteJournal));
|
||||
assert!(!token.allows(TokenScope::WriteMetrics));
|
||||
assert!(!token.allows(TokenScope::ReadProfile));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_token_that_grants_nothing_cannot_be_minted() {
|
||||
assert!(TokenScopes::new([]).is_err());
|
||||
assert!(TokenScopes::from_names(Vec::<&str>::new()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_scope_name_is_refused_rather_than_ignored() {
|
||||
let refused = TokenScopes::from_names(["readJournal", "readEverything"]);
|
||||
|
||||
assert!(
|
||||
refused.is_err(),
|
||||
"a typo must not quietly mint a narrower token than asked for"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scopes_survive_a_round_trip_through_persistence() {
|
||||
let held = TokenScopes::new([TokenScope::ReadJournal, TokenScope::WriteJournal]).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
TokenScopes::from_persistence(&held.to_persistence()),
|
||||
Some(held)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stored_scope_this_build_cannot_read_makes_the_whole_set_unreadable() {
|
||||
assert_eq!(
|
||||
TokenScopes::from_persistence("readJournal writeEverything"),
|
||||
None
|
||||
);
|
||||
assert_eq!(TokenScopes::from_persistence(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_minted_token_keeps_the_secret_out_of_its_debug_output() {
|
||||
let minted = MintedApiToken::new(a_token(), "kmood_supersecretvalue".into());
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
#[path = "reminder/due_occurrence_test.rs"]
|
||||
mod due_occurrence_test;
|
||||
|
||||
#[path = "reminder/reminder_test.rs"]
|
||||
mod reminder_test;
|
||||
|
||||
169
crates/domain/tests/reminder/due_occurrence_test.rs
Normal file
169
crates/domain/tests/reminder/due_occurrence_test.rs
Normal file
@@ -0,0 +1,169 @@
|
||||
use chrono::{DateTime, Duration, NaiveTime, TimeZone, Utc, Weekday};
|
||||
|
||||
use domain::reminder::{DaySchedule, Reminder};
|
||||
use domain::user::{Timezone, UserId};
|
||||
|
||||
const GRACE: Duration = Duration::minutes(30);
|
||||
|
||||
fn warsaw() -> Timezone {
|
||||
Timezone::new("Europe/Warsaw").unwrap()
|
||||
}
|
||||
|
||||
fn at(time: &str) -> NaiveTime {
|
||||
NaiveTime::parse_from_str(time, "%H:%M").unwrap()
|
||||
}
|
||||
|
||||
fn instant(text: &str) -> DateTime<Utc> {
|
||||
DateTime::parse_from_rfc3339(text)
|
||||
.unwrap()
|
||||
.with_timezone(&Utc)
|
||||
}
|
||||
|
||||
fn every_day_at(time: &str) -> Reminder {
|
||||
Reminder::new(UserId::generate(), DaySchedule::every_day_at(at(time)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reminder_is_due_once_its_local_time_has_arrived() {
|
||||
let reminder = every_day_at("20:00");
|
||||
let just_after_eight_in_warsaw = instant("2026-08-27T18:01:00Z");
|
||||
|
||||
assert!(
|
||||
reminder
|
||||
.occurrence_reached(just_after_eight_in_warsaw, &warsaw(), GRACE)
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reminder_is_not_due_before_its_local_time() {
|
||||
let reminder = every_day_at("20:00");
|
||||
let half_past_seven_in_warsaw = instant("2026-08-27T17:30:00Z");
|
||||
|
||||
assert!(
|
||||
reminder
|
||||
.occurrence_reached(half_past_seven_in_warsaw, &warsaw(), GRACE)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_occurrence_is_never_due_twice() {
|
||||
let mut reminder = every_day_at("20:00");
|
||||
let first_sweep = instant("2026-08-27T18:01:00Z");
|
||||
|
||||
let occurrence = reminder
|
||||
.occurrence_reached(first_sweep, &warsaw(), GRACE)
|
||||
.expect("the reminder should be due on the first sweep");
|
||||
|
||||
reminder.mark_sent(occurrence);
|
||||
|
||||
for minute in 2..=25 {
|
||||
let later = instant(&format!("2026-08-27T18:{minute:02}:00Z"));
|
||||
|
||||
assert!(
|
||||
reminder
|
||||
.occurrence_reached(later, &warsaw(), GRACE)
|
||||
.is_none(),
|
||||
"a reminder already sent must not be sent again at 18:{minute:02}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_next_days_occurrence_is_due_again() {
|
||||
let mut reminder = every_day_at("20:00");
|
||||
let today = instant("2026-08-27T18:01:00Z");
|
||||
|
||||
let occurrence = reminder
|
||||
.occurrence_reached(today, &warsaw(), GRACE)
|
||||
.unwrap();
|
||||
reminder.mark_sent(occurrence);
|
||||
|
||||
let tomorrow = instant("2026-08-28T18:01:00Z");
|
||||
|
||||
assert!(
|
||||
reminder
|
||||
.occurrence_reached(tomorrow, &warsaw(), GRACE)
|
||||
.is_some(),
|
||||
"a new day is a new occurrence"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_occurrence_late_yesterday_is_still_reachable_after_local_midnight() {
|
||||
let schedule = {
|
||||
let mut schedule = DaySchedule::new();
|
||||
schedule.set_time(Weekday::Thu, Some(at("23:50")));
|
||||
schedule
|
||||
};
|
||||
let reminder = Reminder::new(UserId::generate(), schedule);
|
||||
|
||||
let ten_past_midnight_on_friday = instant("2026-08-27T22:10:00Z");
|
||||
|
||||
assert!(
|
||||
reminder
|
||||
.occurrence_reached(ten_past_midnight_on_friday, &warsaw(), GRACE)
|
||||
.is_some(),
|
||||
"a reminder scheduled just before midnight must survive the day boundary"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_occurrence_older_than_the_grace_window_is_not_resurrected() {
|
||||
let reminder = every_day_at("08:00");
|
||||
let late_that_evening = instant("2026-08-27T19:00:00Z");
|
||||
|
||||
assert!(
|
||||
reminder
|
||||
.occurrence_reached(late_that_evening, &warsaw(), GRACE)
|
||||
.is_none(),
|
||||
"a worker starting late must not fire this morning's reminder"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_disabled_reminder_is_never_due() {
|
||||
let mut reminder = every_day_at("20:00");
|
||||
reminder.disable();
|
||||
|
||||
assert!(
|
||||
reminder
|
||||
.occurrence_reached(instant("2026-08-27T18:01:00Z"), &warsaw(), GRACE)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_day_with_no_time_set_is_never_due() {
|
||||
let schedule = {
|
||||
let mut schedule = DaySchedule::new();
|
||||
schedule.set_time(Weekday::Mon, Some(at("20:00")));
|
||||
schedule
|
||||
};
|
||||
let reminder = Reminder::new(UserId::generate(), schedule);
|
||||
|
||||
let a_thursday = instant("2026-08-27T18:01:00Z");
|
||||
|
||||
assert!(
|
||||
reminder
|
||||
.occurrence_reached(a_thursday, &warsaw(), GRACE)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scheduled_instant_is_resolved_in_the_users_own_zone() {
|
||||
let reminder = every_day_at("20:00");
|
||||
let warsaw_evening = instant("2026-08-27T18:01:00Z");
|
||||
|
||||
let occurrence = reminder
|
||||
.occurrence_reached(warsaw_evening, &warsaw(), GRACE)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
occurrence.scheduled_for(),
|
||||
Utc.with_ymd_and_hms(2026, 8, 27, 18, 0, 0).unwrap(),
|
||||
"8 PM in Warsaw in August is 18:00Z"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user