Files
movies-diary/crates/domain/src/testing/fakes/store.rs

224 lines
8.0 KiB
Rust

use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use uuid::Uuid;
use crate::models::{
Goal, ImportProfile, ImportSession, Movie, MovieProfile, ProfileField, RefreshSession,
RemoteWatchlistEntry, Review, User, UserSettings, WatchEvent, WatchlistEntry, WebhookToken,
};
use crate::value_objects::SocialIdentity;
/// One `processed_events` row.
pub(super) struct LedgerRow {
pub(super) done: bool,
pub(super) started_at: chrono::NaiveDateTime,
pub(super) processed_at: chrono::NaiveDateTime,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum FollowState {
Pending,
Accepted,
}
/// Tables whose rows the schema removes when a user row is deleted
/// (`ON DELETE CASCADE` on a column referencing `users(id)`), and which
/// `InMemoryStore` models. The sqlite adapter's cascade test asserts the same list.
pub const USER_CASCADE_TABLES: &[&str] = &[
"watchlist_entries",
"goals",
"user_settings",
"user_profile_fields",
"webhook_tokens",
"watch_events",
"ap_followers",
"ap_following",
"blocked_actors",
];
/// Tables that cascade from a user but whose aggregate this store does not hold
/// (they belong to the federation crate).
pub const USER_CASCADE_TABLES_OUTSIDE_STORE: &[&str] = &["ap_local_actors"];
/// Tables whose rows the schema removes when a movie row is deleted.
pub const MOVIE_CASCADE_TABLES: &[&str] = &[
"watchlist_entries",
"movie_profiles",
"movie_genres",
"movie_keywords",
"movie_cast",
"movie_crew",
];
/// One in-memory implementation of every persistence port, mirroring the
/// relational constraints of the sqlite schema (cascades, uniqueness, FK
/// restrictions). Share one `Arc<InMemoryStore>` across all port slots.
pub struct InMemoryStore {
pub(super) movies: RwLock<HashMap<Uuid, Movie>>,
pub(super) movie_profiles: RwLock<HashMap<Uuid, MovieProfile>>,
pub(super) reviews: RwLock<HashMap<Uuid, Review>>,
pub(super) users: RwLock<HashMap<Uuid, User>>,
/// Ids of `users` rows carrying a `deleted_at` tombstone.
pub(super) tombstoned: RwLock<std::collections::HashSet<Uuid>>,
/// Lower-cased `retired_usernames` (the column is NOCASE).
pub(super) retired_usernames: RwLock<std::collections::HashSet<String>>,
pub(super) profile_fields: RwLock<HashMap<Uuid, Vec<ProfileField>>>,
pub(super) user_settings: RwLock<HashMap<Uuid, UserSettings>>,
pub(super) refresh_sessions: RwLock<Vec<RefreshSession>>,
pub(super) watchlist: RwLock<HashMap<(Uuid, Uuid), WatchlistEntry>>,
pub(super) remote_watchlist: RwLock<Vec<RemoteWatchlistEntry>>,
pub(super) goals: RwLock<HashMap<Uuid, Goal>>,
pub(super) watch_events: RwLock<Vec<WatchEvent>>,
pub(super) webhook_tokens: RwLock<Vec<WebhookToken>>,
pub(super) import_sessions: RwLock<HashMap<Uuid, ImportSession>>,
pub(super) import_profiles: RwLock<HashMap<Uuid, ImportProfile>>,
pub(super) follows: RwLock<Vec<(Uuid, SocialIdentity, FollowState)>>,
pub(super) blocked: RwLock<Vec<(Uuid, SocialIdentity)>>,
pub(super) processed_events: RwLock<HashMap<(Uuid, String), LedgerRow>>,
pub(super) recorded_events: RwLock<Vec<crate::events::DomainEvent>>,
}
impl InMemoryStore {
pub fn new() -> Arc<Self> {
Arc::new(Self {
movies: RwLock::default(),
movie_profiles: RwLock::default(),
reviews: RwLock::default(),
users: RwLock::default(),
tombstoned: RwLock::default(),
retired_usernames: RwLock::default(),
profile_fields: RwLock::default(),
user_settings: RwLock::default(),
refresh_sessions: RwLock::default(),
watchlist: RwLock::default(),
remote_watchlist: RwLock::default(),
goals: RwLock::default(),
watch_events: RwLock::default(),
webhook_tokens: RwLock::default(),
import_sessions: RwLock::default(),
import_profiles: RwLock::default(),
follows: RwLock::default(),
blocked: RwLock::default(),
processed_events: RwLock::default(),
recorded_events: RwLock::default(),
})
}
pub fn with_remote_watchlist(entries: Vec<RemoteWatchlistEntry>) -> Arc<Self> {
let store = Self::new();
*store.remote_watchlist.write().unwrap() = entries;
store
}
/// Events recorded atomically with a change (the outbox rows the sqlite
/// adapter would have written in the same transaction), in write order.
pub fn recorded_events(&self) -> Vec<crate::events::DomainEvent> {
self.recorded_events.read().unwrap().clone()
}
pub(super) fn record_events(&self, events: &[crate::events::DomainEvent]) {
self.recorded_events
.write()
.unwrap()
.extend_from_slice(events);
}
pub fn movie_count(&self) -> usize {
self.movies.read().unwrap().len()
}
pub fn movie_profile_count(&self) -> usize {
self.movie_profiles.read().unwrap().len()
}
pub fn review_count(&self) -> usize {
self.reviews.read().unwrap().len()
}
pub fn user_count(&self) -> usize {
self.users.read().unwrap().len()
}
pub fn watchlist_count(&self) -> usize {
self.watchlist.read().unwrap().len()
}
pub fn goal_count(&self) -> usize {
self.goals.read().unwrap().len()
}
pub fn import_session_count(&self) -> usize {
self.import_sessions.read().unwrap().len()
}
pub fn processed_event_count(&self) -> usize {
self.processed_events.read().unwrap().len()
}
/// Inserts a ledger row with explicit timestamps (retention and lease tests).
pub fn seed_processed_event(
&self,
id: &crate::events::EventId,
handler: &str,
done: bool,
at: chrono::NaiveDateTime,
) {
self.processed_events.write().unwrap().insert(
(id.value(), handler.to_owned()),
LedgerRow {
done,
started_at: at,
processed_at: at,
},
);
}
pub fn refresh_session_count(&self) -> usize {
self.refresh_sessions.read().unwrap().len()
}
/// Inserts a user bypassing the uniqueness checks of `UserRepository::save`.
pub fn seed_user(&self, user: User) {
self.users.write().unwrap().insert(user.id().value(), user);
}
/// Row count of the sqlite table of the same name, for the tables this
/// store models. Panics on a table the store does not hold.
pub fn rows_in(&self, table: &str) -> usize {
match table {
"watchlist_entries" => self.watchlist_count(),
"goals" => self.goal_count(),
"user_settings" => self.user_settings.read().unwrap().len(),
"user_profile_fields" => self
.profile_fields
.read()
.unwrap()
.values()
.map(Vec::len)
.sum(),
"webhook_tokens" => self.webhook_tokens.read().unwrap().len(),
"watch_events" => self.watch_events.read().unwrap().len(),
"ap_following" => self.follows.read().unwrap().len(),
"ap_followers" => self
.follows
.read()
.unwrap()
.iter()
.filter(|(_, target, _)| matches!(target, SocialIdentity::Local(_)))
.count(),
"blocked_actors" => self.blocked.read().unwrap().len(),
"movie_profiles" => self.movie_profile_count(),
"movie_genres" => self.sum_profiles(|p| p.genres.len()),
"movie_keywords" => self.sum_profiles(|p| p.keywords.len()),
"movie_cast" => self.sum_profiles(|p| p.cast.len()),
"movie_crew" => self.sum_profiles(|p| p.crew.len()),
other => panic!("InMemoryStore does not model table {other}"),
}
}
fn sum_profiles(&self, f: impl Fn(&MovieProfile) -> usize) -> usize {
self.movie_profiles.read().unwrap().values().map(f).sum()
}
}