init
Some checks failed
CI / ci (push) Failing after 1m48s

This commit is contained in:
2026-08-25 23:24:36 +02:00
commit 95739892de
466 changed files with 33918 additions and 0 deletions

View File

@@ -0,0 +1,65 @@
use chrono::{DateTime, FixedOffset, NaiveTime, TimeZone, Utc};
use crate::activity::{Activity, ActivityName, CategoryName};
use crate::attachment::ContentType;
use crate::entry::{Content, DateRange, Mood, MoodEntry};
use crate::reminder::{DaySchedule, Reminder};
use crate::user::{Email, PasswordHash, User, UserId, Username};
pub fn test_user(name: &str) -> User {
User::new(
Username::new(name).unwrap(),
Email::new(format!("{name}@example.com")).unwrap(),
PasswordHash::new(format!("hashed:{name}")),
)
}
pub fn test_entry(user_id: UserId, mood: Mood) -> MoodEntry {
MoodEntry::new(user_id, mood, test_logged_at())
}
pub fn test_entry_days_ago(user_id: UserId, mood: Mood, days_ago: i64) -> MoodEntry {
let offset = FixedOffset::east_opt(0).unwrap();
let logged_at =
offset.from_utc_datetime(&(Utc::now() - chrono::Duration::days(days_ago)).naive_utc());
MoodEntry::new(user_id, mood, logged_at)
}
pub fn test_activity(user_id: UserId, name: &str) -> Activity {
Activity::new(user_id, ActivityName::new(name).unwrap(), None)
}
pub fn test_activity_with_category(user_id: UserId, name: &str, category: &str) -> Activity {
Activity::new(
user_id,
ActivityName::new(name).unwrap(),
Some(CategoryName::new(category).unwrap()),
)
}
pub fn test_reminder(user_id: UserId) -> Reminder {
let time = NaiveTime::from_hms_opt(20, 0, 0).unwrap();
Reminder::new(user_id, DaySchedule::every_day_at(time))
}
pub fn test_content(text: &str) -> Content {
Content::new(text).unwrap()
}
pub fn test_content_type(mime: &str) -> ContentType {
ContentType::new(mime).unwrap()
}
pub fn test_logged_at() -> DateTime<FixedOffset> {
FixedOffset::east_opt(3600)
.unwrap()
.with_ymd_and_hms(2025, 6, 15, 20, 0, 0)
.unwrap()
}
pub fn test_date_range_last_n_days(days: i64) -> DateRange {
let offset = FixedOffset::east_opt(0).unwrap();
let now = offset.from_utc_datetime(&Utc::now().naive_utc());
let start = offset.from_utc_datetime(&(Utc::now() - chrono::Duration::days(days)).naive_utc());
DateRange::new(start, now).unwrap()
}

View File

@@ -0,0 +1,11 @@
mod password_hasher;
mod store;
mod store_activity;
mod store_auth;
mod store_entry;
mod store_infra;
mod store_reminder;
mod store_user;
pub use password_hasher::FakePasswordHasher;
pub use store::InMemoryStore;

View File

@@ -0,0 +1,14 @@
use crate::errors::DomainError;
use crate::user::PasswordHash;
pub struct FakePasswordHasher;
impl crate::ports::PasswordHasherPort for FakePasswordHasher {
fn hash(&self, raw_password: &str) -> Result<PasswordHash, DomainError> {
Ok(PasswordHash::new(format!("hashed:{raw_password}")))
}
fn verify(&self, raw_password: &str, hash: &PasswordHash) -> Result<bool, DomainError> {
Ok(hash.value() == format!("hashed:{raw_password}"))
}
}

View File

@@ -0,0 +1,66 @@
use std::collections::HashMap;
use std::sync::RwLock;
use crate::activity::{Activity, ActivityId};
use crate::auth::RefreshSession;
use crate::entry::{MoodEntry, MoodEntryId};
use crate::events::EventEnvelope;
use crate::push::{PushSubscription, PushSubscriptionId};
use crate::reminder::{Reminder, ReminderId};
use crate::user::{User, UserId};
pub struct InMemoryStore {
pub(super) entries: RwLock<HashMap<MoodEntryId, MoodEntry>>,
pub(super) activities: RwLock<HashMap<ActivityId, Activity>>,
pub(super) users: RwLock<HashMap<UserId, User>>,
pub(super) reminders: RwLock<HashMap<ReminderId, Reminder>>,
pub refresh_sessions: RwLock<Vec<RefreshSession>>,
pub(super) push_subscriptions: RwLock<HashMap<PushSubscriptionId, PushSubscription>>,
pub(super) events: RwLock<Vec<EventEnvelope>>,
pub(super) sent_reminders: RwLock<Vec<UserId>>,
}
impl InMemoryStore {
pub fn new() -> Self {
Self {
entries: RwLock::new(HashMap::new()),
activities: RwLock::new(HashMap::new()),
users: RwLock::new(HashMap::new()),
reminders: RwLock::new(HashMap::new()),
refresh_sessions: RwLock::new(Vec::new()),
push_subscriptions: RwLock::new(HashMap::new()),
events: RwLock::new(Vec::new()),
sent_reminders: RwLock::new(Vec::new()),
}
}
pub fn published_events(&self) -> Vec<EventEnvelope> {
self.events.read().unwrap().clone()
}
pub fn sent_reminders(&self) -> Vec<UserId> {
self.sent_reminders.read().unwrap().clone()
}
pub fn entry_count(&self) -> usize {
self.entries.read().unwrap().len()
}
pub fn activity_count(&self) -> usize {
self.activities.read().unwrap().len()
}
pub fn reminder_count(&self) -> usize {
self.reminders.read().unwrap().len()
}
pub fn user_count(&self) -> usize {
self.users.read().unwrap().len()
}
}
impl Default for InMemoryStore {
fn default() -> Self {
Self::new()
}
}

View File

@@ -0,0 +1,58 @@
use crate::activity::{Activity, ActivityId};
use crate::errors::DomainError;
use crate::user::UserId;
use super::InMemoryStore;
#[async_trait::async_trait]
impl crate::ports::ActivityCommandPort for InMemoryStore {
async fn save(&self, activity: &Activity) -> Result<(), DomainError> {
self.activities
.write()
.unwrap()
.insert(activity.id().clone(), activity.clone());
Ok(())
}
async fn delete(&self, id: &ActivityId) -> Result<(), DomainError> {
self.activities.write().unwrap().remove(id);
Ok(())
}
async fn delete_all_by_user(&self, user_id: &UserId) -> Result<(), DomainError> {
self.activities
.write()
.unwrap()
.retain(|_, a| a.user_id() != user_id);
Ok(())
}
}
#[async_trait::async_trait]
impl crate::ports::ActivityQueryPort for InMemoryStore {
async fn find_by_id(&self, id: &ActivityId) -> Result<Option<Activity>, DomainError> {
Ok(self.activities.read().unwrap().get(id).cloned())
}
async fn find_by_user(&self, user_id: &UserId) -> Result<Vec<Activity>, DomainError> {
Ok(self
.activities
.read()
.unwrap()
.values()
.filter(|a| a.user_id() == user_id)
.cloned()
.collect())
}
async fn find_active_by_user(&self, user_id: &UserId) -> Result<Vec<Activity>, DomainError> {
Ok(self
.activities
.read()
.unwrap()
.values()
.filter(|a| a.user_id() == user_id && !a.is_archived())
.cloned()
.collect())
}
}

View File

@@ -0,0 +1,72 @@
use chrono::{Duration, Utc};
use crate::auth::{GeneratedToken, RefreshSession};
use crate::errors::DomainError;
use crate::user::UserId;
use super::InMemoryStore;
#[async_trait::async_trait]
impl crate::ports::AuthServicePort for InMemoryStore {
async fn generate_token(&self, user_id: &UserId) -> Result<GeneratedToken, DomainError> {
let token = format!("fake-jwt-{}", user_id.value());
let expires_at = Utc::now() + Duration::hours(1);
Ok(GeneratedToken::new(token, expires_at))
}
async fn validate_token(&self, token: &str) -> Result<UserId, DomainError> {
let uuid_str = token
.strip_prefix("fake-jwt-")
.ok_or_else(|| DomainError::Unauthorized("invalid token".into()))?;
let uuid: uuid::Uuid = uuid_str
.parse()
.map_err(|_| DomainError::Unauthorized("invalid token".into()))?;
Ok(UserId::from_uuid(uuid))
}
}
#[async_trait::async_trait]
impl crate::ports::RefreshSessionCommandPort for InMemoryStore {
async fn create(&self, session: &RefreshSession) -> Result<(), DomainError> {
self.refresh_sessions.write().unwrap().push(session.clone());
Ok(())
}
async fn revoke(&self, token: &str) -> Result<(), DomainError> {
self.refresh_sessions
.write()
.unwrap()
.retain(|s| s.token() != token);
Ok(())
}
async fn revoke_all_for_user(&self, user_id: &UserId) -> Result<(), DomainError> {
self.refresh_sessions
.write()
.unwrap()
.retain(|s| s.user_id() != user_id);
Ok(())
}
async fn delete_expired(&self) -> Result<u64, DomainError> {
let mut sessions = self.refresh_sessions.write().unwrap();
let before = sessions.len();
sessions.retain(|s| !s.is_expired());
Ok((before - sessions.len()) as u64)
}
}
#[async_trait::async_trait]
impl crate::ports::RefreshSessionQueryPort for InMemoryStore {
async fn find_by_token(&self, token: &str) -> Result<Option<RefreshSession>, DomainError> {
Ok(self
.refresh_sessions
.read()
.unwrap()
.iter()
.find(|s| s.token() == token)
.cloned())
}
}

View File

@@ -0,0 +1,160 @@
use crate::activity::ActivityId;
use crate::entry::{DateRange, Mood, MoodEntry, MoodEntryId};
use crate::errors::DomainError;
use crate::user::UserId;
use super::InMemoryStore;
#[async_trait::async_trait]
impl crate::ports::MoodEntryCommandPort for InMemoryStore {
async fn save(&self, entry: &MoodEntry) -> Result<(), DomainError> {
self.entries
.write()
.unwrap()
.insert(entry.id().clone(), entry.clone());
Ok(())
}
async fn save_batch(&self, entries: &[MoodEntry]) -> Result<(), DomainError> {
let mut store = self.entries.write().unwrap();
for entry in entries {
store.insert(entry.id().clone(), entry.clone());
}
Ok(())
}
async fn delete(&self, id: &MoodEntryId) -> Result<(), DomainError> {
self.entries.write().unwrap().remove(id);
Ok(())
}
async fn delete_all_by_user(&self, user_id: &UserId) -> Result<(), DomainError> {
self.entries
.write()
.unwrap()
.retain(|_, e| e.user_id() != user_id);
Ok(())
}
async fn delete_by_date_range(
&self,
user_id: &UserId,
range: &DateRange,
) -> Result<u64, DomainError> {
let mut store = self.entries.write().unwrap();
let before = store.len();
store.retain(|_, e| {
e.user_id() != user_id || e.logged_at() < range.start() || e.logged_at() > range.end()
});
Ok((before - store.len()) as u64)
}
async fn replace_activity(
&self,
user_id: &UserId,
old_activity_id: &ActivityId,
new_activity_id: &ActivityId,
) -> Result<u64, DomainError> {
let mut store = self.entries.write().unwrap();
let mut count = 0u64;
for entry in store.values_mut() {
if entry.user_id() != user_id {
continue;
}
let activities: Vec<_> = entry.activities().to_vec();
if activities.contains(old_activity_id) {
let replaced: Vec<_> = activities
.into_iter()
.map(|a| {
if &a == old_activity_id {
new_activity_id.clone()
} else {
a
}
})
.collect();
entry.set_activities(replaced);
count += 1;
}
}
Ok(count)
}
}
#[async_trait::async_trait]
impl crate::ports::MoodEntryQueryPort for InMemoryStore {
async fn find_by_id(&self, id: &MoodEntryId) -> Result<Option<MoodEntry>, DomainError> {
Ok(self.entries.read().unwrap().get(id).cloned())
}
async fn find_by_user(
&self,
user_id: &UserId,
limit: Option<i64>,
offset: Option<i64>,
) -> Result<Vec<MoodEntry>, DomainError> {
let mut entries: Vec<_> = self
.entries
.read()
.unwrap()
.values()
.filter(|e| e.user_id() == user_id)
.cloned()
.collect();
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(entries)
}
async fn find_by_date_range(
&self,
user_id: &UserId,
range: &DateRange,
) -> Result<Vec<MoodEntry>, DomainError> {
Ok(self
.entries
.read()
.unwrap()
.values()
.filter(|e| {
e.user_id() == user_id
&& e.logged_at() >= range.start()
&& e.logged_at() <= range.end()
})
.cloned()
.collect())
}
async fn find_by_mood(
&self,
user_id: &UserId,
mood: Mood,
) -> Result<Vec<MoodEntry>, DomainError> {
Ok(self
.entries
.read()
.unwrap()
.values()
.filter(|e| e.user_id() == user_id && e.mood() == mood)
.cloned()
.collect())
}
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 && e.activities().contains(activity_id))
.cloned()
.collect())
}
}

View File

@@ -0,0 +1,191 @@
use crate::attachment::{MediaUpload, PhotoId, VoiceMemoId};
use crate::entry::{DateRange, MoodEntry};
use crate::errors::DomainError;
use crate::events::EventEnvelope;
use crate::ports::ImportedRow;
use crate::push::{PushSubscription, PushSubscriptionId};
use crate::user::UserId;
use super::InMemoryStore;
#[async_trait::async_trait]
impl crate::ports::EventPublisherPort for InMemoryStore {
async fn publish(&self, envelope: EventEnvelope) -> Result<(), DomainError> {
self.events.write().unwrap().push(envelope);
Ok(())
}
}
#[async_trait::async_trait]
impl crate::ports::MediaStoragePort for InMemoryStore {
async fn store_photo(&self, _upload: MediaUpload) -> Result<PhotoId, DomainError> {
Ok(PhotoId::generate())
}
async fn store_voice_memo(&self, _upload: MediaUpload) -> Result<VoiceMemoId, DomainError> {
Ok(VoiceMemoId::generate())
}
async fn get_photo(
&self,
_id: &PhotoId,
) -> Result<Option<crate::ports::MediaFile>, DomainError> {
Ok(None)
}
async fn get_voice_memo(
&self,
_id: &VoiceMemoId,
) -> Result<Option<crate::ports::MediaFile>, DomainError> {
Ok(None)
}
async fn delete_photo(&self, _id: &PhotoId) -> Result<(), DomainError> {
Ok(())
}
async fn delete_voice_memo(&self, _id: &VoiceMemoId) -> Result<(), DomainError> {
Ok(())
}
}
#[async_trait::async_trait]
impl crate::ports::CascadeDeletePort for InMemoryStore {
async fn delete_all_user_data(&self, user_id: &UserId) -> Result<(), DomainError> {
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);
Ok(())
}
async fn delete_user_account(&self, user_id: &UserId) -> Result<(), DomainError> {
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.refresh_sessions
.write()
.unwrap()
.retain(|s| s.user_id() != user_id);
self.push_subscriptions
.write()
.unwrap()
.retain(|_, s| s.user_id() != user_id);
self.users.write().unwrap().remove(user_id);
Ok(())
}
async fn delete_entries_in_range(
&self,
user_id: &UserId,
range: &DateRange,
) -> Result<Vec<MoodEntry>, DomainError> {
let mut entries = self.entries.write().unwrap();
let mut removed = Vec::new();
entries.retain(|_, e| {
if e.user_id() == user_id
&& e.logged_at() >= range.start()
&& e.logged_at() <= range.end()
{
removed.push(e.clone());
false
} else {
true
}
});
Ok(removed)
}
}
#[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());
Ok(())
}
async fn delete(&self, id: &PushSubscriptionId) -> Result<(), DomainError> {
self.push_subscriptions.write().unwrap().remove(id);
Ok(())
}
async fn delete_by_endpoint(&self, endpoint: &str) -> Result<(), DomainError> {
self.push_subscriptions
.write()
.unwrap()
.retain(|_, s| s.endpoint() != endpoint);
Ok(())
}
async fn delete_all_by_user(&self, user_id: &UserId) -> Result<(), DomainError> {
self.push_subscriptions
.write()
.unwrap()
.retain(|_, s| s.user_id() != user_id);
Ok(())
}
}
#[async_trait::async_trait]
impl crate::ports::PushSubscriptionQueryPort for InMemoryStore {
async fn find_by_user(&self, user_id: &UserId) -> Result<Vec<PushSubscription>, DomainError> {
Ok(self
.push_subscriptions
.read()
.unwrap()
.values()
.filter(|s| s.user_id() == user_id)
.cloned()
.collect())
}
async fn find_by_endpoint(
&self,
endpoint: &str,
) -> Result<Option<PushSubscription>, DomainError> {
Ok(self
.push_subscriptions
.read()
.unwrap()
.values()
.find(|s| s.endpoint() == endpoint)
.cloned())
}
}
#[async_trait::async_trait]
impl crate::ports::ExportPort for InMemoryStore {
async fn export_user_data(
&self,
_data: &crate::ports::UserExport,
) -> Result<Vec<u8>, DomainError> {
Ok(b"exported".to_vec())
}
}
#[async_trait::async_trait]
impl crate::ports::ImportSourcePort for InMemoryStore {
async fn read_entries(&self, _data: &[u8]) -> Result<Vec<ImportedRow>, DomainError> {
Ok(Vec::new())
}
}

View File

@@ -0,0 +1,66 @@
use crate::errors::DomainError;
use crate::reminder::{Reminder, ReminderId};
use crate::user::UserId;
use super::InMemoryStore;
#[async_trait::async_trait]
impl crate::ports::ReminderCommandPort for InMemoryStore {
async fn save(&self, reminder: &Reminder) -> Result<(), DomainError> {
self.reminders
.write()
.unwrap()
.insert(reminder.id().clone(), reminder.clone());
Ok(())
}
async fn delete(&self, id: &ReminderId) -> Result<(), DomainError> {
self.reminders.write().unwrap().remove(id);
Ok(())
}
async fn delete_all_by_user(&self, user_id: &UserId) -> Result<(), DomainError> {
self.reminders
.write()
.unwrap()
.retain(|_, r| r.user_id() != user_id);
Ok(())
}
}
#[async_trait::async_trait]
impl crate::ports::ReminderQueryPort for InMemoryStore {
async fn find_by_id(&self, id: &ReminderId) -> Result<Option<Reminder>, DomainError> {
Ok(self.reminders.read().unwrap().get(id).cloned())
}
async fn find_by_user(&self, user_id: &UserId) -> Result<Vec<Reminder>, DomainError> {
Ok(self
.reminders
.read()
.unwrap()
.values()
.filter(|r| r.user_id() == user_id)
.cloned()
.collect())
}
async fn find_all_enabled(&self) -> Result<Vec<Reminder>, DomainError> {
Ok(self
.reminders
.read()
.unwrap()
.values()
.filter(|r| r.is_enabled())
.cloned()
.collect())
}
}
#[async_trait::async_trait]
impl crate::ports::ReminderSenderPort for InMemoryStore {
async fn send_reminder(&self, user_id: &UserId) -> Result<(), DomainError> {
self.sent_reminders.write().unwrap().push(user_id.clone());
Ok(())
}
}

View File

@@ -0,0 +1,47 @@
use crate::errors::DomainError;
use crate::user::{Email, User, UserId, Username};
use super::InMemoryStore;
#[async_trait::async_trait]
impl crate::ports::UserCommandPort for InMemoryStore {
async fn save(&self, user: &User) -> Result<(), DomainError> {
self.users
.write()
.unwrap()
.insert(user.id().clone(), user.clone());
Ok(())
}
async fn delete(&self, id: &UserId) -> Result<(), DomainError> {
self.users.write().unwrap().remove(id);
Ok(())
}
}
#[async_trait::async_trait]
impl crate::ports::UserQueryPort for InMemoryStore {
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
Ok(self.users.read().unwrap().get(id).cloned())
}
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
Ok(self
.users
.read()
.unwrap()
.values()
.find(|u| u.username() == username)
.cloned())
}
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
Ok(self
.users
.read()
.unwrap()
.values()
.find(|u| u.email() == email)
.cloned())
}
}

View File

@@ -0,0 +1,5 @@
mod factories;
mod fakes;
pub use factories::*;
pub use fakes::{FakePasswordHasher, InMemoryStore};