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",
|
||||
};
|
||||
|
||||
// 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 ?",
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,3 +30,4 @@
|
||||
30 DAAA0AF35B4ACB271A9D8068B82F02A72EEC62F3AC07ED504C7A7887976C2C8BACB35FFF477B63A931DF15F79018390D
|
||||
100 310ADC9ABF0CC2F8E9EDAB33168D009D64228FF5BD8D2A14185513699EBB782606A1EEA8FEE6EC6AF65D72396153F13F
|
||||
101 19A2542D4343F3921A8BF0A10E195DA1915E3EA97A2BCF2D3BF9079504DB2053E4BD2175D94C95BCA363BC2FDF14AA88
|
||||
102 A2118D59757F99A902B613046CE93C53927FE64330684A8703B6E62A8588B87D37ACD55451566F6248A67B7BD9B779EF
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user