feat: user tombstone and retired usernames

This commit is contained in:
2026-10-26 02:03:36 +01:00
parent bde8040cc3
commit 31f5d5f337
15 changed files with 550 additions and 56 deletions

View File

@@ -0,0 +1,6 @@
ALTER TABLE users ADD COLUMN deleted_at TEXT;
CREATE TABLE retired_usernames (
username TEXT PRIMARY KEY COLLATE NOCASE,
retired_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);

View File

@@ -131,11 +131,16 @@ impl DiaryQuery for SqliteDiaryRepository {
FeedSortBy::RatingAsc => "r.rating ASC, r.watched_at ASC",
};
// Tombstoned local authors vanish from the feed; remote and orphan
// rows have no `users` match, so `deleted_at` is NULL for them.
where_parts.push("u.deleted_at IS NULL".to_string());
let where_clause = where_parts.join(" AND ");
let count_sql = format!(
"SELECT COUNT(*) FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
LEFT JOIN users u ON u.id = r.user_id
WHERE {}",
where_clause
);
@@ -304,12 +309,14 @@ impl DiaryQuery for SqliteDiaryRepository {
let limit = page.limit as i64;
let offset = page.offset as i64;
let total: i64 =
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews WHERE movie_id = ?")
.bind(&id_str)
.fetch_one(&self.pool)
.await
.map_err(crate::shared::map_sqlx_error)?;
let total: i64 = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM reviews r LEFT JOIN users u ON u.id = r.user_id
WHERE r.movie_id = ? AND u.deleted_at IS NULL",
)
.bind(&id_str)
.fetch_one(&self.pool)
.await
.map_err(crate::shared::map_sqlx_error)?;
let rows = sqlx::query_as::<_, FeedRow>(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
@@ -321,7 +328,7 @@ impl DiaryQuery for SqliteDiaryRepository {
INNER JOIN movies m ON m.id = r.movie_id
LEFT JOIN users u ON u.id = r.user_id
LEFT JOIN ap_remote_actors a ON a.url = r.remote_actor_url
WHERE r.movie_id = ?
WHERE r.movie_id = ? AND u.deleted_at IS NULL
ORDER BY r.watched_at DESC
LIMIT ? OFFSET ?",
)

View File

@@ -20,7 +20,8 @@ impl domain::ports::FollowQuery for SqliteSocialRepository {
FROM ap_following f
LEFT JOIN users u ON f.remote_actor_url = ?1 || '/users/' || u.id
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ?2 AND f.status = 'accepted'",
WHERE f.local_user_id = ?2 AND f.status = 'accepted'
AND u.deleted_at IS NULL",
)
.bind(self.instance.base_url())
.bind(&uid)
@@ -42,7 +43,8 @@ impl domain::ports::FollowQuery for SqliteSocialRepository {
FROM ap_followers f
LEFT JOIN users u ON f.remote_actor_url = ?1 || '/users/' || u.id
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ?2 AND f.status = 'accepted'",
WHERE f.local_user_id = ?2 AND f.status = 'accepted'
AND u.deleted_at IS NULL",
)
.bind(self.instance.base_url())
.bind(&uid)
@@ -67,7 +69,8 @@ impl domain::ports::FollowQuery for SqliteSocialRepository {
FROM ap_followers f
LEFT JOIN users u ON f.remote_actor_url = ?1 || '/users/' || u.id
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ?2 AND f.status = 'pending'",
WHERE f.local_user_id = ?2 AND f.status = 'pending'
AND u.deleted_at IS NULL",
)
.bind(self.instance.base_url())
.bind(&uid)
@@ -92,7 +95,8 @@ impl domain::ports::FollowQuery for SqliteSocialRepository {
FROM ap_following f
LEFT JOIN users u ON f.remote_actor_url = ?1 || '/users/' || u.id
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ?2 AND f.status = 'pending'",
WHERE f.local_user_id = ?2 AND f.status = 'pending'
AND u.deleted_at IS NULL",
)
.bind(self.instance.base_url())
.bind(&uid)

View File

@@ -66,11 +66,13 @@ const USER_COLS: &str = "id, email, username, password_hash, role, display_name,
impl UserRepository for SqliteUserRepository {
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
let email_str = email.value();
let row = sqlx::query(&format!("SELECT {USER_COLS} FROM users WHERE email = ?"))
.bind(email_str)
.fetch_optional(&self.pool)
.await
.map_err(crate::shared::map_sqlx_error)?;
let row = sqlx::query(&format!(
"SELECT {USER_COLS} FROM users WHERE email = ? AND deleted_at IS NULL"
))
.bind(email_str)
.fetch_optional(&self.pool)
.await
.map_err(crate::shared::map_sqlx_error)?;
row.as_ref()
.map(|r| Self::row_to_user(r, vec![]))
@@ -79,11 +81,13 @@ impl UserRepository for SqliteUserRepository {
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
let username_str = username.value();
let row = sqlx::query(&format!("SELECT {USER_COLS} FROM users WHERE username = ?"))
.bind(username_str)
.fetch_optional(&self.pool)
.await
.map_err(crate::shared::map_sqlx_error)?;
let row = sqlx::query(&format!(
"SELECT {USER_COLS} FROM users WHERE username = ? AND deleted_at IS NULL"
))
.bind(username_str)
.fetch_optional(&self.pool)
.await
.map_err(crate::shared::map_sqlx_error)?;
row.as_ref()
.map(|r| Self::row_to_user(r, vec![]))
@@ -91,12 +95,12 @@ impl UserRepository for SqliteUserRepository {
}
async fn save(&self, user: &User) -> Result<(), DomainError> {
if self.find_by_email(user.email()).await?.is_some() {
if self.row_exists("email", user.email().value()).await? {
return Err(DomainError::ValidationError(
"Email already registered".into(),
));
}
if self.find_by_username(user.username()).await?.is_some() {
if self.row_exists("username", user.username().value()).await? {
return Err(DomainError::ValidationError(
"Username already taken".into(),
));
@@ -130,31 +134,86 @@ impl UserRepository for SqliteUserRepository {
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
let id_str = id.value().to_string();
let row = sqlx::query(&format!("SELECT {USER_COLS} FROM users WHERE id = ?"))
.bind(&id_str)
.fetch_optional(&self.pool)
let row = sqlx::query(&format!(
"SELECT {USER_COLS} FROM users WHERE id = ? AND deleted_at IS NULL"
))
.bind(&id_str)
.fetch_optional(&self.pool)
.await
.map_err(crate::shared::map_sqlx_error)?;
self.load_with_fields(&id_str, row).await
}
async fn find_tombstoned_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
let id_str = id.value().to_string();
let row = sqlx::query(&format!(
"SELECT {USER_COLS} FROM users WHERE id = ? AND deleted_at IS NOT NULL"
))
.bind(&id_str)
.fetch_optional(&self.pool)
.await
.map_err(crate::shared::map_sqlx_error)?;
self.load_with_fields(&id_str, row).await
}
async fn mark_deleted(
&self,
user_id: &UserId,
events: &[domain::events::DomainEvent],
) -> Result<(), DomainError> {
let id_str = user_id.value().to_string();
let mut tx = self
.pool
.begin()
.await
.map_err(crate::shared::map_sqlx_error)?;
let Some(r) = row else { return Ok(None) };
let field_rows = sqlx::query(
"SELECT name, value FROM user_profile_fields WHERE user_id = ? ORDER BY position ASC",
let stamped =
sqlx::query("UPDATE users SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL")
.bind(Utc::now().to_rfc3339())
.bind(&id_str)
.execute(&mut *tx)
.await
.map_err(crate::shared::map_sqlx_error)?;
if stamped.rows_affected() == 0 {
return Err(DomainError::NotFound("User not found".into()));
}
sqlx::query(
"INSERT OR IGNORE INTO retired_usernames (username) SELECT username FROM users WHERE id = ?",
)
.bind(&id_str)
.fetch_all(&self.pool)
.execute(&mut *tx)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
.map_err(crate::shared::map_sqlx_error)?;
sqlx::query("DELETE FROM refresh_sessions WHERE user_id = ?")
.bind(&id_str)
.execute(&mut *tx)
.await
.map_err(crate::shared::map_sqlx_error)?;
crate::shared::enqueue_events(&mut tx, events).await?;
tx.commit().await.map_err(crate::shared::map_sqlx_error)
}
let profile_fields = field_rows
.iter()
.map(|f| ProfileField {
name: f.get("name"),
value: f.get("value"),
})
.collect();
async fn is_username_retired(&self, username: &Username) -> Result<bool, DomainError> {
sqlx::query_scalar::<_, i64>(
"SELECT EXISTS(SELECT 1 FROM retired_usernames WHERE username = ?)",
)
.bind(username.value())
.fetch_one(&self.pool)
.await
.map(|n| n != 0)
.map_err(crate::shared::map_sqlx_error)
}
Self::row_to_user(&r, profile_fields).map(Some)
async fn count_active_admins(&self) -> Result<u64, DomainError> {
sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM users WHERE role = 'admin' AND deleted_at IS NULL",
)
.fetch_one(&self.pool)
.await
.map(|n| n as u64)
.map_err(crate::shared::map_sqlx_error)
}
async fn update_profile(
@@ -193,6 +252,7 @@ impl UserRepository for SqliteUserRepository {
u.avatar_path
FROM users u
LEFT JOIN reviews r ON r.user_id = u.id AND r.remote_actor_url IS NULL
WHERE u.deleted_at IS NULL
GROUP BY u.id, u.email, u.username, u.display_name, u.avatar_path
ORDER BY u.email ASC"#,
)
@@ -204,3 +264,44 @@ impl UserRepository for SqliteUserRepository {
.collect()
}
}
impl SqliteUserRepository {
/// Uniqueness check over every row, tombstones included: a deleted
/// account keeps its email and username until the purge removes the row.
async fn row_exists(&self, column: &str, value: &str) -> Result<bool, DomainError> {
sqlx::query_scalar::<_, i64>(&format!(
"SELECT EXISTS(SELECT 1 FROM users WHERE {column} = ?)"
))
.bind(value)
.fetch_one(&self.pool)
.await
.map(|n| n != 0)
.map_err(crate::shared::map_sqlx_error)
}
async fn load_with_fields(
&self,
id_str: &str,
row: Option<sqlx::sqlite::SqliteRow>,
) -> Result<Option<User>, DomainError> {
let Some(r) = row else { return Ok(None) };
let field_rows = sqlx::query(
"SELECT name, value FROM user_profile_fields WHERE user_id = ? ORDER BY position ASC",
)
.bind(id_str)
.fetch_all(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let profile_fields = field_rows
.iter()
.map(|f| ProfileField {
name: f.get("name"),
value: f.get("value"),
})
.collect();
Self::row_to_user(&r, profile_fields).map(Some)
}
}

View File

@@ -30,3 +30,4 @@
30 DAAA0AF35B4ACB271A9D8068B82F02A72EEC62F3AC07ED504C7A7887976C2C8BACB35FFF477B63A931DF15F79018390D
100 310ADC9ABF0CC2F8E9EDAB33168D009D64228FF5BD8D2A14185513699EBB782606A1EEA8FEE6EC6AF65D72396153F13F
101 19A2542D4343F3921A8BF0A10E195DA1915E3EA97A2BCF2D3BF9079504DB2053E4BD2175D94C95BCA363BC2FDF14AA88
102 A2118D59757F99A902B613046CE93C53927FE64330684A8703B6E62A8588B87D37ACD55451566F6248A67B7BD9B779EF

View File

@@ -205,3 +205,32 @@ async fn count_local_posts_excludes_remote_reviews() {
let count = repo.count_local_posts().await.unwrap();
assert_eq!(count, 2);
}
#[tokio::test]
async fn feeds_hide_reviews_of_deleted_local_authors() {
let pool = SqlitePool::connect(":memory:").await.unwrap();
setup(&pool).await;
sqlx::query("UPDATE users SET deleted_at = '2026-01-01T00:00:00Z' WHERE username = 'alice'")
.execute(&pool)
.await
.unwrap();
let repo = SqliteDiaryRepository::new(pool);
let page = PageParams::new(Some(10), Some(0)).unwrap();
let feed = repo
.query_activity_feed_filtered(&page, &FeedSortBy::Date, None, None)
.await
.unwrap();
assert_eq!(feed.total_count, 2);
assert!(
feed.items
.iter()
.all(|e| e.movie().title().value() != "Inception")
);
let movie = domain::value_objects::MovieId::from_uuid(
uuid::Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap(),
);
let social = repo.get_movie_social_feed(&movie, &page).await.unwrap();
assert!(social.items.is_empty());
}

View File

@@ -13,7 +13,7 @@ async fn test_pool() -> SqlitePool {
follow_activity_id TEXT NOT NULL, created_at TEXT, status TEXT NOT NULL,
PRIMARY KEY (local_user_id, remote_actor_url))",
"CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL,
display_name TEXT, avatar_path TEXT)",
display_name TEXT, avatar_path TEXT, deleted_at TEXT)",
"CREATE TABLE ap_remote_actors (url TEXT PRIMARY KEY, handle TEXT NOT NULL,
display_name TEXT, avatar_url TEXT)",
] {
@@ -251,3 +251,40 @@ async fn list_all_followed_remote_actors_deduplicates_across_users() {
assert_eq!(actors.len(), 1);
assert_eq!(actors[0].handle, "alice@other.social");
}
#[tokio::test]
async fn follow_lists_omit_deleted_local_users() {
let pool = test_pool().await;
let viewer = uuid::Uuid::new_v4();
let gone = uuid::Uuid::new_v4();
let live = uuid::Uuid::new_v4();
sqlx::query(
"INSERT INTO users (id, username, deleted_at) VALUES (?1, 'gone', '2026-01-01T00:00:00Z'), (?2, 'live', NULL)",
)
.bind(gone.to_string())
.bind(live.to_string())
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status)
VALUES (?1, 'https://md.example/users/' || ?2, '', 'accepted'),
(?1, 'https://md.example/users/' || ?3, '', 'accepted'),
(?1, 'https://other.example/users/remote', '', 'accepted')",
)
.bind(viewer.to_string())
.bind(gone.to_string())
.bind(live.to_string())
.execute(&pool)
.await
.unwrap();
let actors = FollowQuery::get_following(&repo(pool), viewer)
.await
.unwrap();
assert_eq!(actors.len(), 2, "deleted local user must not be listed");
assert!(!actors.iter().any(
|a| a.identity == SocialIdentity::Local(domain::value_objects::UserId::from_uuid(gone))
));
}

View File

@@ -5,20 +5,10 @@ use domain::value_objects::UserId;
use sqlite::SqliteUserRepository;
use sqlx::SqlitePool;
use super::outbox_support::{count, fail_event_inserts, migrated_pool};
async fn setup() -> (SqlitePool, SqliteUserRepository) {
let pool = SqlitePool::connect(":memory:").await.unwrap();
sqlx::query(
"CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'standard', display_name TEXT, bio TEXT, avatar_path TEXT, banner_path TEXT, also_known_as TEXT)"
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"CREATE TABLE user_profile_fields (id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, value TEXT NOT NULL, position INTEGER NOT NULL DEFAULT 0)"
)
.execute(&pool)
.await
.unwrap();
let pool = migrated_pool().await;
let repo = SqliteUserRepository::new(pool.clone());
(pool, repo)
}
@@ -101,3 +91,126 @@ async fn update_profile_clears_fields_with_none() {
assert_eq!(found.bio(), None);
assert_eq!(found.avatar_path(), None);
}
fn deleted_event(user: &domain::models::User) -> domain::events::DomainEvent {
domain::events::DomainEvent::UserDeleted {
user_id: user.id().clone(),
}
}
async fn add_session(pool: &SqlitePool, user_id: &UserId, token: &str) {
sqlx::query(
"INSERT INTO refresh_sessions (id, user_id, token, expires_at, created_at) VALUES (?, ?, ?, ?, ?)",
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(user_id.value().to_string())
.bind(token)
.bind("2099-01-01T00:00:00+00:00")
.bind("2026-01-01T00:00:00+00:00")
.execute(pool)
.await
.unwrap();
}
#[tokio::test]
async fn marking_a_user_deleted_hides_them_from_every_lookup() {
let (_, repo) = setup().await;
let admin =
domain::testing::test_user_with_role(UserId::generate(), domain::models::UserRole::Admin);
repo.save(&admin).await.unwrap();
let other = test_user("other");
repo.save(&other).await.unwrap();
assert_eq!(repo.count_active_admins().await.unwrap(), 1);
repo.mark_deleted(admin.id(), &[deleted_event(&admin)])
.await
.unwrap();
assert!(repo.find_by_id(admin.id()).await.unwrap().is_none());
assert!(repo.find_by_email(admin.email()).await.unwrap().is_none());
assert!(
repo.find_by_username(admin.username())
.await
.unwrap()
.is_none()
);
let listed = repo.list_with_stats().await.unwrap();
let emails: Vec<_> = listed.iter().map(|u| u.email()).collect();
assert!(emails.contains(&other.email().value()));
assert!(!emails.contains(&admin.email().value()));
assert_eq!(repo.count_active_admins().await.unwrap(), 0);
// The purge can still read the tombstone; a live user is not one.
assert!(
repo.find_tombstoned_by_id(admin.id())
.await
.unwrap()
.is_some()
);
assert!(
repo.find_tombstoned_by_id(other.id())
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn marking_deleted_retires_the_username_and_drops_sessions_atomically() {
let (pool, repo) = setup().await;
let user = test_user("leaver");
let keeper = test_user("keeper");
repo.save(&user).await.unwrap();
repo.save(&keeper).await.unwrap();
add_session(&pool, user.id(), "t1").await;
add_session(&pool, user.id(), "t2").await;
add_session(&pool, keeper.id(), "t3").await;
repo.mark_deleted(user.id(), &[deleted_event(&user)])
.await
.unwrap();
assert!(repo.is_username_retired(user.username()).await.unwrap());
assert!(!repo.is_username_retired(keeper.username()).await.unwrap());
assert_eq!(count(&pool, "refresh_sessions").await, 1, "only keeper's");
assert_eq!(count(&pool, "event_queue").await, 1);
let deleted_at: Option<String> =
sqlx::query_scalar("SELECT deleted_at FROM users WHERE id = ?")
.bind(user.id().value().to_string())
.fetch_one(&pool)
.await
.unwrap();
assert!(deleted_at.is_some());
}
#[tokio::test]
async fn a_failed_enqueue_rolls_back_tombstone_retirement_and_session_drop() {
let (pool, repo) = setup().await;
let user = test_user("stayer");
repo.save(&user).await.unwrap();
add_session(&pool, user.id(), "t1").await;
fail_event_inserts(&pool).await;
let result = repo.mark_deleted(user.id(), &[deleted_event(&user)]).await;
assert!(result.is_err());
assert!(repo.find_by_id(user.id()).await.unwrap().is_some());
assert!(!repo.is_username_retired(user.username()).await.unwrap());
assert_eq!(count(&pool, "retired_usernames").await, 0);
assert_eq!(count(&pool, "refresh_sessions").await, 1);
assert_eq!(count(&pool, "event_queue").await, 0);
}
#[tokio::test]
async fn retired_names_match_case_insensitively() {
let (_, repo) = setup().await;
let user = test_user("MixedCase");
repo.save(&user).await.unwrap();
repo.mark_deleted(user.id(), &[deleted_event(&user)])
.await
.unwrap();
let lower = domain::value_objects::Username::new("mixedcase".to_string()).unwrap();
let upper = domain::value_objects::Username::new("MIXEDCASE".to_string()).unwrap();
assert!(repo.is_username_retired(&lower).await.unwrap());
assert!(repo.is_username_retired(&upper).await.unwrap());
}

View File

@@ -314,6 +314,28 @@ impl UserRepository for ProfileWriteRefusingUsers {
"refused".into(),
))
}
async fn mark_deleted(
&self,
id: &domain::value_objects::UserId,
events: &[DomainEvent],
) -> Result<(), domain::errors::DomainError> {
self.0.mark_deleted(id, events).await
}
async fn find_tombstoned_by_id(
&self,
id: &domain::value_objects::UserId,
) -> Result<Option<domain::models::User>, domain::errors::DomainError> {
self.0.find_tombstoned_by_id(id).await
}
async fn is_username_retired(
&self,
u: &domain::value_objects::Username,
) -> Result<bool, domain::errors::DomainError> {
self.0.is_username_retired(u).await
}
async fn count_active_admins(&self) -> Result<u64, domain::errors::DomainError> {
self.0.count_active_admins().await
}
}
#[tokio::test]

View File

@@ -227,6 +227,28 @@ impl UserRepository for ErroringUserRepo {
) -> Result<(), DomainError> {
panic!()
}
async fn mark_deleted(
&self,
_: &UserId,
_: &[domain::events::DomainEvent],
) -> Result<(), DomainError> {
panic!()
}
async fn find_tombstoned_by_id(
&self,
_: &UserId,
) -> Result<Option<domain::models::User>, DomainError> {
panic!()
}
async fn is_username_retired(
&self,
_: &domain::value_objects::Username,
) -> Result<bool, DomainError> {
panic!()
}
async fn count_active_admins(&self) -> Result<u64, DomainError> {
panic!()
}
}
/// Auth stub that accepts any bearer token and resolves it to a fixed user id,

View File

@@ -14,6 +14,8 @@ pub trait AuthService: Send + Sync {
#[async_trait]
pub trait UserRepository: Send + Sync {
// The finders and `list_with_stats` serve live users only: tombstoned
// (deleted) accounts are invisible to them.
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>;
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError>;
async fn save(&self, user: &User) -> Result<(), DomainError>;
@@ -25,6 +27,19 @@ pub trait UserRepository: Send + Sync {
profile: &crate::models::UserProfile,
events: &[crate::events::DomainEvent],
) -> Result<(), DomainError>;
/// Tombstones the account in one transaction: stamps `deleted_at`,
/// retires the username, drops the user's refresh sessions and records
/// `events`. Afterwards every reader below behaves as if the user is gone.
async fn mark_deleted(
&self,
user_id: &UserId,
events: &[crate::events::DomainEvent],
) -> Result<(), DomainError>;
/// The tombstoned user, for the purge. Live users are not returned.
async fn find_tombstoned_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError>;
/// Whether the name belonged to a deleted account (case-insensitive).
async fn is_username_retired(&self, username: &Username) -> Result<bool, DomainError>;
async fn count_active_admins(&self) -> Result<u64, DomainError>;
}
#[async_trait]

View File

@@ -668,6 +668,24 @@ impl UserRepository for PanicUserRepository {
) -> Result<(), DomainError> {
panic!("PanicUserRepository called")
}
async fn mark_deleted(&self, _: &UserId, _: &[DomainEvent]) -> Result<(), DomainError> {
panic!("PanicUserRepository called")
}
async fn find_tombstoned_by_id(
&self,
_: &UserId,
) -> Result<Option<crate::models::User>, DomainError> {
panic!("PanicUserRepository called")
}
async fn is_username_retired(
&self,
_: &crate::value_objects::Username,
) -> Result<bool, DomainError> {
panic!("PanicUserRepository called")
}
async fn count_active_admins(&self) -> Result<u64, DomainError> {
panic!("PanicUserRepository called")
}
}
pub struct PanicEventPublisher;

View File

@@ -59,6 +59,10 @@ pub struct InMemoryStore {
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>>,
@@ -82,6 +86,8 @@ impl InMemoryStore {
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(),

View File

@@ -16,16 +16,20 @@ use super::InMemoryStore;
impl UserRepository for InMemoryStore {
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
let store = self.users.read().unwrap();
let gone = self.tombstoned.read().unwrap();
Ok(store
.values()
.filter(|u| !gone.contains(&u.id().value()))
.find(|u| u.email().value() == email.value())
.cloned())
}
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
let store = self.users.read().unwrap();
let gone = self.tombstoned.read().unwrap();
Ok(store
.values()
.filter(|u| !gone.contains(&u.id().value()))
.find(|u| u.username().value() == username.value())
.cloned())
}
@@ -55,14 +59,69 @@ impl UserRepository for InMemoryStore {
}
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
if self.tombstoned.read().unwrap().contains(&id.value()) {
return Ok(None);
}
Ok(self.users.read().unwrap().get(&id.value()).cloned())
}
async fn find_tombstoned_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
if !self.tombstoned.read().unwrap().contains(&id.value()) {
return Ok(None);
}
Ok(self.users.read().unwrap().get(&id.value()).cloned())
}
async fn mark_deleted(
&self,
user_id: &UserId,
events: &[crate::events::DomainEvent],
) -> Result<(), DomainError> {
let id = user_id.value();
if let Some(user) = self.users.read().unwrap().get(&id) {
self.retired_usernames
.write()
.unwrap()
.insert(user.username().value().to_lowercase());
self.tombstoned.write().unwrap().insert(id);
}
self.refresh_sessions
.write()
.unwrap()
.retain(|s| s.user_id != *user_id);
self.record_events(events);
Ok(())
}
async fn is_username_retired(&self, username: &Username) -> Result<bool, DomainError> {
Ok(self
.retired_usernames
.read()
.unwrap()
.contains(&username.value().to_lowercase()))
}
async fn count_active_admins(&self) -> Result<u64, DomainError> {
let gone = self.tombstoned.read().unwrap();
Ok(self
.users
.read()
.unwrap()
.values()
.filter(|u| matches!(u.role(), crate::models::UserRole::Admin))
.filter(|u| !gone.contains(&u.id().value()))
.count() as u64)
}
/// Ordered by email ascending like the real query. Diary stats are stubbed
/// (review count 0, no last activity); only the identity fields are real.
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError> {
let store = self.users.read().unwrap();
let mut users: Vec<_> = store.values().collect();
let gone = self.tombstoned.read().unwrap();
let mut users: Vec<_> = store
.values()
.filter(|u| !gone.contains(&u.id().value()))
.collect();
users.sort_by(|a, b| a.email().value().cmp(b.email().value()));
Ok(users
.into_iter()
@@ -161,6 +220,7 @@ impl InMemoryStore {
pub fn delete_user(&self, user_id: &UserId) {
let id = user_id.value();
self.users.write().unwrap().remove(&id);
self.tombstoned.write().unwrap().remove(&id);
self.watchlist
.write()
.unwrap()

View File

@@ -575,3 +575,56 @@ async fn deleting_a_review_while_another_remains_keeps_the_movie_and_records_one
assert_eq!(store.movie_count(), 1);
assert_eq!(store.recorded_events().len(), 1);
}
#[tokio::test]
async fn fake_mark_deleted_mirrors_the_sqlite_tombstone() {
let store = InMemoryStore::new();
let user = test_user("ghost");
let other = test_user("other");
UserRepository::save(&*store, &user).await.unwrap();
UserRepository::save(&*store, &other).await.unwrap();
RefreshSessionRepository::create(
&*store,
&RefreshSession {
id: Uuid::new_v4(),
user_id: user.id().clone(),
token: "t".into(),
expires_at: Utc::now(),
created_at: Utc::now(),
},
)
.await
.unwrap();
let event = domain::events::DomainEvent::UserDeleted {
user_id: user.id().clone(),
};
store
.mark_deleted(user.id(), std::slice::from_ref(&event))
.await
.unwrap();
assert!(store.find_by_id(user.id()).await.unwrap().is_none());
assert!(store.find_by_email(user.email()).await.unwrap().is_none());
assert!(
store
.find_by_username(user.username())
.await
.unwrap()
.is_none()
);
assert_eq!(store.list_with_stats().await.unwrap().len(), 1);
assert!(
store
.find_tombstoned_by_id(user.id())
.await
.unwrap()
.is_some()
);
assert!(store.is_username_retired(user.username()).await.unwrap());
let upper =
domain::value_objects::Username::new(user.username().value().to_uppercase()).unwrap();
assert!(store.is_username_retired(&upper).await.unwrap());
assert_eq!(store.refresh_session_count(), 0);
assert_eq!(store.recorded_events().len(), 1);
}