feat: user tombstone and retired usernames
This commit is contained in:
@@ -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'))
|
||||||
|
);
|
||||||
@@ -131,11 +131,16 @@ impl DiaryQuery for SqliteDiaryRepository {
|
|||||||
FeedSortBy::RatingAsc => "r.rating ASC, r.watched_at ASC",
|
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 where_clause = where_parts.join(" AND ");
|
||||||
|
|
||||||
let count_sql = format!(
|
let count_sql = format!(
|
||||||
"SELECT COUNT(*) FROM reviews r
|
"SELECT COUNT(*) FROM reviews r
|
||||||
INNER JOIN movies m ON m.id = r.movie_id
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
|
LEFT JOIN users u ON u.id = r.user_id
|
||||||
WHERE {}",
|
WHERE {}",
|
||||||
where_clause
|
where_clause
|
||||||
);
|
);
|
||||||
@@ -304,8 +309,10 @@ impl DiaryQuery for SqliteDiaryRepository {
|
|||||||
let limit = page.limit as i64;
|
let limit = page.limit as i64;
|
||||||
let offset = page.offset as i64;
|
let offset = page.offset as i64;
|
||||||
|
|
||||||
let total: i64 =
|
let total: i64 = sqlx::query_scalar::<_, i64>(
|
||||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews WHERE movie_id = ?")
|
"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)
|
.bind(&id_str)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await
|
.await
|
||||||
@@ -321,7 +328,7 @@ impl DiaryQuery for SqliteDiaryRepository {
|
|||||||
INNER JOIN movies m ON m.id = r.movie_id
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
LEFT JOIN users u ON u.id = r.user_id
|
LEFT JOIN users u ON u.id = r.user_id
|
||||||
LEFT JOIN ap_remote_actors a ON a.url = r.remote_actor_url
|
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
|
ORDER BY r.watched_at DESC
|
||||||
LIMIT ? OFFSET ?",
|
LIMIT ? OFFSET ?",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ impl domain::ports::FollowQuery for SqliteSocialRepository {
|
|||||||
FROM ap_following f
|
FROM ap_following f
|
||||||
LEFT JOIN users u ON f.remote_actor_url = ?1 || '/users/' || u.id
|
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
|
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(self.instance.base_url())
|
||||||
.bind(&uid)
|
.bind(&uid)
|
||||||
@@ -42,7 +43,8 @@ impl domain::ports::FollowQuery for SqliteSocialRepository {
|
|||||||
FROM ap_followers f
|
FROM ap_followers f
|
||||||
LEFT JOIN users u ON f.remote_actor_url = ?1 || '/users/' || u.id
|
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
|
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(self.instance.base_url())
|
||||||
.bind(&uid)
|
.bind(&uid)
|
||||||
@@ -67,7 +69,8 @@ impl domain::ports::FollowQuery for SqliteSocialRepository {
|
|||||||
FROM ap_followers f
|
FROM ap_followers f
|
||||||
LEFT JOIN users u ON f.remote_actor_url = ?1 || '/users/' || u.id
|
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
|
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(self.instance.base_url())
|
||||||
.bind(&uid)
|
.bind(&uid)
|
||||||
@@ -92,7 +95,8 @@ impl domain::ports::FollowQuery for SqliteSocialRepository {
|
|||||||
FROM ap_following f
|
FROM ap_following f
|
||||||
LEFT JOIN users u ON f.remote_actor_url = ?1 || '/users/' || u.id
|
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
|
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(self.instance.base_url())
|
||||||
.bind(&uid)
|
.bind(&uid)
|
||||||
|
|||||||
@@ -66,7 +66,9 @@ const USER_COLS: &str = "id, email, username, password_hash, role, display_name,
|
|||||||
impl UserRepository for SqliteUserRepository {
|
impl UserRepository for SqliteUserRepository {
|
||||||
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
||||||
let email_str = email.value();
|
let email_str = email.value();
|
||||||
let row = sqlx::query(&format!("SELECT {USER_COLS} FROM users WHERE email = ?"))
|
let row = sqlx::query(&format!(
|
||||||
|
"SELECT {USER_COLS} FROM users WHERE email = ? AND deleted_at IS NULL"
|
||||||
|
))
|
||||||
.bind(email_str)
|
.bind(email_str)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
@@ -79,7 +81,9 @@ impl UserRepository for SqliteUserRepository {
|
|||||||
|
|
||||||
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
|
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
|
||||||
let username_str = username.value();
|
let username_str = username.value();
|
||||||
let row = sqlx::query(&format!("SELECT {USER_COLS} FROM users WHERE username = ?"))
|
let row = sqlx::query(&format!(
|
||||||
|
"SELECT {USER_COLS} FROM users WHERE username = ? AND deleted_at IS NULL"
|
||||||
|
))
|
||||||
.bind(username_str)
|
.bind(username_str)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
@@ -91,12 +95,12 @@ impl UserRepository for SqliteUserRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn save(&self, user: &User) -> Result<(), DomainError> {
|
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(
|
return Err(DomainError::ValidationError(
|
||||||
"Email already registered".into(),
|
"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(
|
return Err(DomainError::ValidationError(
|
||||||
"Username already taken".into(),
|
"Username already taken".into(),
|
||||||
));
|
));
|
||||||
@@ -130,31 +134,86 @@ impl UserRepository for SqliteUserRepository {
|
|||||||
|
|
||||||
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
||||||
let id_str = id.value().to_string();
|
let id_str = id.value().to_string();
|
||||||
let row = sqlx::query(&format!("SELECT {USER_COLS} FROM users WHERE id = ?"))
|
let row = sqlx::query(&format!(
|
||||||
|
"SELECT {USER_COLS} FROM users WHERE id = ? AND deleted_at IS NULL"
|
||||||
|
))
|
||||||
.bind(&id_str)
|
.bind(&id_str)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(crate::shared::map_sqlx_error)?;
|
.map_err(crate::shared::map_sqlx_error)?;
|
||||||
|
|
||||||
let Some(r) = row else { return Ok(None) };
|
self.load_with_fields(&id_str, row).await
|
||||||
|
}
|
||||||
|
|
||||||
let field_rows = sqlx::query(
|
async fn find_tombstoned_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
||||||
"SELECT name, value FROM user_profile_fields WHERE user_id = ? ORDER BY position ASC",
|
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 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)
|
.bind(&id_str)
|
||||||
.fetch_all(&self.pool)
|
.execute(&mut *tx)
|
||||||
.await
|
.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
|
async fn is_username_retired(&self, username: &Username) -> Result<bool, DomainError> {
|
||||||
.iter()
|
sqlx::query_scalar::<_, i64>(
|
||||||
.map(|f| ProfileField {
|
"SELECT EXISTS(SELECT 1 FROM retired_usernames WHERE username = ?)",
|
||||||
name: f.get("name"),
|
)
|
||||||
value: f.get("value"),
|
.bind(username.value())
|
||||||
})
|
.fetch_one(&self.pool)
|
||||||
.collect();
|
.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(
|
async fn update_profile(
|
||||||
@@ -193,6 +252,7 @@ impl UserRepository for SqliteUserRepository {
|
|||||||
u.avatar_path
|
u.avatar_path
|
||||||
FROM users u
|
FROM users u
|
||||||
LEFT JOIN reviews r ON r.user_id = u.id AND r.remote_actor_url IS NULL
|
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
|
GROUP BY u.id, u.email, u.username, u.display_name, u.avatar_path
|
||||||
ORDER BY u.email ASC"#,
|
ORDER BY u.email ASC"#,
|
||||||
)
|
)
|
||||||
@@ -204,3 +264,44 @@ impl UserRepository for SqliteUserRepository {
|
|||||||
.collect()
|
.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,3 +30,4 @@
|
|||||||
30 DAAA0AF35B4ACB271A9D8068B82F02A72EEC62F3AC07ED504C7A7887976C2C8BACB35FFF477B63A931DF15F79018390D
|
30 DAAA0AF35B4ACB271A9D8068B82F02A72EEC62F3AC07ED504C7A7887976C2C8BACB35FFF477B63A931DF15F79018390D
|
||||||
100 310ADC9ABF0CC2F8E9EDAB33168D009D64228FF5BD8D2A14185513699EBB782606A1EEA8FEE6EC6AF65D72396153F13F
|
100 310ADC9ABF0CC2F8E9EDAB33168D009D64228FF5BD8D2A14185513699EBB782606A1EEA8FEE6EC6AF65D72396153F13F
|
||||||
101 19A2542D4343F3921A8BF0A10E195DA1915E3EA97A2BCF2D3BF9079504DB2053E4BD2175D94C95BCA363BC2FDF14AA88
|
101 19A2542D4343F3921A8BF0A10E195DA1915E3EA97A2BCF2D3BF9079504DB2053E4BD2175D94C95BCA363BC2FDF14AA88
|
||||||
|
102 A2118D59757F99A902B613046CE93C53927FE64330684A8703B6E62A8588B87D37ACD55451566F6248A67B7BD9B779EF
|
||||||
|
|||||||
@@ -205,3 +205,32 @@ async fn count_local_posts_excludes_remote_reviews() {
|
|||||||
let count = repo.count_local_posts().await.unwrap();
|
let count = repo.count_local_posts().await.unwrap();
|
||||||
assert_eq!(count, 2);
|
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());
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ async fn test_pool() -> SqlitePool {
|
|||||||
follow_activity_id TEXT NOT NULL, created_at TEXT, status TEXT NOT NULL,
|
follow_activity_id TEXT NOT NULL, created_at TEXT, status TEXT NOT NULL,
|
||||||
PRIMARY KEY (local_user_id, remote_actor_url))",
|
PRIMARY KEY (local_user_id, remote_actor_url))",
|
||||||
"CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL,
|
"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,
|
"CREATE TABLE ap_remote_actors (url TEXT PRIMARY KEY, handle TEXT NOT NULL,
|
||||||
display_name TEXT, avatar_url TEXT)",
|
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.len(), 1);
|
||||||
assert_eq!(actors[0].handle, "alice@other.social");
|
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))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,20 +5,10 @@ use domain::value_objects::UserId;
|
|||||||
use sqlite::SqliteUserRepository;
|
use sqlite::SqliteUserRepository;
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use super::outbox_support::{count, fail_event_inserts, migrated_pool};
|
||||||
|
|
||||||
async fn setup() -> (SqlitePool, SqliteUserRepository) {
|
async fn setup() -> (SqlitePool, SqliteUserRepository) {
|
||||||
let pool = SqlitePool::connect(":memory:").await.unwrap();
|
let pool = migrated_pool().await;
|
||||||
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 repo = SqliteUserRepository::new(pool.clone());
|
let repo = SqliteUserRepository::new(pool.clone());
|
||||||
(pool, repo)
|
(pool, repo)
|
||||||
}
|
}
|
||||||
@@ -101,3 +91,126 @@ async fn update_profile_clears_fields_with_none() {
|
|||||||
assert_eq!(found.bio(), None);
|
assert_eq!(found.bio(), None);
|
||||||
assert_eq!(found.avatar_path(), 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());
|
||||||
|
}
|
||||||
|
|||||||
@@ -314,6 +314,28 @@ impl UserRepository for ProfileWriteRefusingUsers {
|
|||||||
"refused".into(),
|
"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]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -227,6 +227,28 @@ impl UserRepository for ErroringUserRepo {
|
|||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
panic!()
|
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,
|
/// Auth stub that accepts any bearer token and resolves it to a fixed user id,
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ pub trait AuthService: Send + Sync {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait UserRepository: Send + Sync {
|
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_email(&self, email: &Email) -> Result<Option<User>, DomainError>;
|
||||||
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError>;
|
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError>;
|
||||||
async fn save(&self, user: &User) -> Result<(), DomainError>;
|
async fn save(&self, user: &User) -> Result<(), DomainError>;
|
||||||
@@ -25,6 +27,19 @@ pub trait UserRepository: Send + Sync {
|
|||||||
profile: &crate::models::UserProfile,
|
profile: &crate::models::UserProfile,
|
||||||
events: &[crate::events::DomainEvent],
|
events: &[crate::events::DomainEvent],
|
||||||
) -> Result<(), DomainError>;
|
) -> 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]
|
#[async_trait]
|
||||||
|
|||||||
@@ -668,6 +668,24 @@ impl UserRepository for PanicUserRepository {
|
|||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
panic!("PanicUserRepository called")
|
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;
|
pub struct PanicEventPublisher;
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ pub struct InMemoryStore {
|
|||||||
pub(super) movie_profiles: RwLock<HashMap<Uuid, MovieProfile>>,
|
pub(super) movie_profiles: RwLock<HashMap<Uuid, MovieProfile>>,
|
||||||
pub(super) reviews: RwLock<HashMap<Uuid, Review>>,
|
pub(super) reviews: RwLock<HashMap<Uuid, Review>>,
|
||||||
pub(super) users: RwLock<HashMap<Uuid, User>>,
|
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) profile_fields: RwLock<HashMap<Uuid, Vec<ProfileField>>>,
|
||||||
pub(super) user_settings: RwLock<HashMap<Uuid, UserSettings>>,
|
pub(super) user_settings: RwLock<HashMap<Uuid, UserSettings>>,
|
||||||
pub(super) refresh_sessions: RwLock<Vec<RefreshSession>>,
|
pub(super) refresh_sessions: RwLock<Vec<RefreshSession>>,
|
||||||
@@ -82,6 +86,8 @@ impl InMemoryStore {
|
|||||||
movie_profiles: RwLock::default(),
|
movie_profiles: RwLock::default(),
|
||||||
reviews: RwLock::default(),
|
reviews: RwLock::default(),
|
||||||
users: RwLock::default(),
|
users: RwLock::default(),
|
||||||
|
tombstoned: RwLock::default(),
|
||||||
|
retired_usernames: RwLock::default(),
|
||||||
profile_fields: RwLock::default(),
|
profile_fields: RwLock::default(),
|
||||||
user_settings: RwLock::default(),
|
user_settings: RwLock::default(),
|
||||||
refresh_sessions: RwLock::default(),
|
refresh_sessions: RwLock::default(),
|
||||||
|
|||||||
@@ -16,16 +16,20 @@ use super::InMemoryStore;
|
|||||||
impl UserRepository for InMemoryStore {
|
impl UserRepository for InMemoryStore {
|
||||||
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
||||||
let store = self.users.read().unwrap();
|
let store = self.users.read().unwrap();
|
||||||
|
let gone = self.tombstoned.read().unwrap();
|
||||||
Ok(store
|
Ok(store
|
||||||
.values()
|
.values()
|
||||||
|
.filter(|u| !gone.contains(&u.id().value()))
|
||||||
.find(|u| u.email().value() == email.value())
|
.find(|u| u.email().value() == email.value())
|
||||||
.cloned())
|
.cloned())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
|
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
|
||||||
let store = self.users.read().unwrap();
|
let store = self.users.read().unwrap();
|
||||||
|
let gone = self.tombstoned.read().unwrap();
|
||||||
Ok(store
|
Ok(store
|
||||||
.values()
|
.values()
|
||||||
|
.filter(|u| !gone.contains(&u.id().value()))
|
||||||
.find(|u| u.username().value() == username.value())
|
.find(|u| u.username().value() == username.value())
|
||||||
.cloned())
|
.cloned())
|
||||||
}
|
}
|
||||||
@@ -55,14 +59,69 @@ impl UserRepository for InMemoryStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
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())
|
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
|
/// Ordered by email ascending like the real query. Diary stats are stubbed
|
||||||
/// (review count 0, no last activity); only the identity fields are real.
|
/// (review count 0, no last activity); only the identity fields are real.
|
||||||
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError> {
|
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError> {
|
||||||
let store = self.users.read().unwrap();
|
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()));
|
users.sort_by(|a, b| a.email().value().cmp(b.email().value()));
|
||||||
Ok(users
|
Ok(users
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -161,6 +220,7 @@ impl InMemoryStore {
|
|||||||
pub fn delete_user(&self, user_id: &UserId) {
|
pub fn delete_user(&self, user_id: &UserId) {
|
||||||
let id = user_id.value();
|
let id = user_id.value();
|
||||||
self.users.write().unwrap().remove(&id);
|
self.users.write().unwrap().remove(&id);
|
||||||
|
self.tombstoned.write().unwrap().remove(&id);
|
||||||
self.watchlist
|
self.watchlist
|
||||||
.write()
|
.write()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|||||||
@@ -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.movie_count(), 1);
|
||||||
assert_eq!(store.recorded_events().len(), 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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user