max / makenotwork
5 files changed,
+241 insertions,
-66 deletions
A
multithreaded/.sqlx/query-fede228e8a0209fde5f50a26baf05d01fc5a1319e1cc61386428f294979d8cf2.json
+15
| @@ -0,0 +1,15 @@ | |||
| 1 | + | { | |
| 2 | + | "db_name": "PostgreSQL", | |
| 3 | + | "query": "UPDATE users\n SET username = 'mnw_' || mnw_account_id::text, updated_at = now()\n WHERE username = $1 AND mnw_account_id <> $2", | |
| 4 | + | "describe": { | |
| 5 | + | "columns": [], | |
| 6 | + | "parameters": { | |
| 7 | + | "Left": [ | |
| 8 | + | "Text", | |
| 9 | + | "Uuid" | |
| 10 | + | ] | |
| 11 | + | }, | |
| 12 | + | "nullable": [] | |
| 13 | + | }, | |
| 14 | + | "hash": "fede228e8a0209fde5f50a26baf05d01fc5a1319e1cc61386428f294979d8cf2" | |
| 15 | + | } |
| @@ -34,6 +34,8 @@ pub async fn upsert_user( | |||
| 34 | 34 | username: &str, | |
| 35 | 35 | display_name: Option<&str>, | |
| 36 | 36 | ) -> Result<(), sqlx::Error> { | |
| 37 | + | let mut tx = pool.begin().await?; | |
| 38 | + | vacate_stale_username(&mut tx, mnw_account_id, username).await?; | |
| 37 | 39 | sqlx::query!( | |
| 38 | 40 | "INSERT INTO users (mnw_account_id, username, display_name) | |
| 39 | 41 | VALUES ($1, $2, $3) | |
| @@ -43,11 +45,49 @@ pub async fn upsert_user( | |||
| 43 | 45 | username, | |
| 44 | 46 | display_name, | |
| 45 | 47 | ) | |
| 46 | - | .execute(pool) | |
| 48 | + | .execute(&mut *tx) | |
| 49 | + | .await?; | |
| 50 | + | tx.commit().await?; | |
| 51 | + | Ok(()) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /// Free `username` from any *other* user row that still holds it, so the caller's | |
| 55 | + | /// upsert can claim it without tripping the `users_username_key` unique index. | |
| 56 | + | /// | |
| 57 | + | /// MNW is the source of truth for usernames and they are reusable there; a local | |
| 58 | + | /// mirror row holding a name its MNW account no longer owns is stale and must | |
| 59 | + | /// yield. Run this on the same transaction as the upsert (a single statement | |
| 60 | + | /// can't, because a non-deferrable unique index is checked per-row and the | |
| 61 | + | /// rename wouldn't yet be visible to the insert). The vacated row keeps its data | |
| 62 | + | /// under a collision-proof `mnw_<account-id>` placeholder until that user's own | |
| 63 | + | /// next login resets it. Closes the username-collision login lockout (S2). | |
| 64 | + | async fn vacate_stale_username( | |
| 65 | + | conn: &mut sqlx::PgConnection, | |
| 66 | + | mnw_account_id: Uuid, | |
| 67 | + | username: &str, | |
| 68 | + | ) -> Result<(), sqlx::Error> { | |
| 69 | + | sqlx::query!( | |
| 70 | + | "UPDATE users | |
| 71 | + | SET username = 'mnw_' || mnw_account_id::text, updated_at = now() | |
| 72 | + | WHERE username = $1 AND mnw_account_id <> $2", | |
| 73 | + | username, | |
| 74 | + | mnw_account_id, | |
| 75 | + | ) | |
| 76 | + | .execute(&mut *conn) | |
| 47 | 77 | .await?; | |
| 48 | 78 | Ok(()) | |
| 49 | 79 | } | |
| 50 | 80 | ||
| 81 | + | /// Public wrapper over [`vacate_stale_username`] for the OAuth login upsert in the | |
| 82 | + | /// web crate, which builds its INSERT with a runtime query. | |
| 83 | + | pub async fn vacate_username_for_login( | |
| 84 | + | conn: &mut sqlx::PgConnection, | |
| 85 | + | mnw_account_id: Uuid, | |
| 86 | + | username: &str, | |
| 87 | + | ) -> Result<(), sqlx::Error> { | |
| 88 | + | vacate_stale_username(conn, mnw_account_id, username).await | |
| 89 | + | } | |
| 90 | + | ||
| 51 | 91 | /// Ensure a user has a membership in a community with the given role. | |
| 52 | 92 | /// Creates membership if none exists, does nothing if already a member. | |
| 53 | 93 | #[tracing::instrument(skip_all)] | |
| @@ -175,7 +215,28 @@ pub async fn create_thread_with_op( | |||
| 175 | 215 | body_html: &str, | |
| 176 | 216 | ) -> Result<(Uuid, Uuid), sqlx::Error> { | |
| 177 | 217 | let mut tx = pool.begin().await?; | |
| 218 | + | let ids = | |
| 219 | + | create_thread_with_op_tx(&mut tx, category_id, author_id, title, body_markdown, body_html) | |
| 220 | + | .await?; | |
| 221 | + | tx.commit().await?; | |
| 222 | + | Ok(ids) | |
| 223 | + | } | |
| 178 | 224 | ||
| 225 | + | /// Create a thread and its opening post on the caller's transaction. | |
| 226 | + | /// | |
| 227 | + | /// Lets a handler fold the thread, its OP, its mentions, and its tags into one | |
| 228 | + | /// atomic unit so a mid-sequence failure can't leave a thread whose @mentions or | |
| 229 | + | /// tags silently vanished (ultra-fuzz M-St1). The pool-taking | |
| 230 | + | /// [`create_thread_with_op`] wraps this in its own transaction. | |
| 231 | + | #[tracing::instrument(skip_all)] | |
| 232 | + | pub async fn create_thread_with_op_tx( | |
| 233 | + | conn: &mut sqlx::PgConnection, | |
| 234 | + | category_id: Uuid, | |
| 235 | + | author_id: Uuid, | |
| 236 | + | title: &str, | |
| 237 | + | body_markdown: &str, | |
| 238 | + | body_html: &str, | |
| 239 | + | ) -> Result<(Uuid, Uuid), sqlx::Error> { | |
| 179 | 240 | let thread_id = sqlx::query_scalar!( | |
| 180 | 241 | "INSERT INTO threads (category_id, author_id, title) | |
| 181 | 242 | VALUES ($1, $2, $3) | |
| @@ -184,7 +245,7 @@ pub async fn create_thread_with_op( | |||
| 184 | 245 | author_id, | |
| 185 | 246 | title, | |
| 186 | 247 | ) | |
| 187 | - | .fetch_one(&mut *tx) | |
| 248 | + | .fetch_one(&mut *conn) | |
| 188 | 249 | .await?; | |
| 189 | 250 | ||
| 190 | 251 | let post_id = sqlx::query_scalar!( | |
| @@ -196,10 +257,9 @@ pub async fn create_thread_with_op( | |||
| 196 | 257 | body_markdown, | |
| 197 | 258 | body_html, | |
| 198 | 259 | ) | |
| 199 | - | .fetch_one(&mut *tx) | |
| 260 | + | .fetch_one(&mut *conn) | |
| 200 | 261 | .await?; | |
| 201 | 262 | ||
| 202 | - | tx.commit().await?; | |
| 203 | 263 | Ok((thread_id, post_id)) | |
| 204 | 264 | } | |
| 205 | 265 | ||
| @@ -218,7 +278,25 @@ pub async fn create_post( | |||
| 218 | 278 | body_html: &str, | |
| 219 | 279 | ) -> Result<Uuid, sqlx::Error> { | |
| 220 | 280 | let mut tx = pool.begin().await?; | |
| 281 | + | let post_id = create_post_tx(&mut tx, thread_id, author_id, body_markdown, body_html).await?; | |
| 282 | + | tx.commit().await?; | |
| 283 | + | Ok(post_id) | |
| 284 | + | } | |
| 221 | 285 | ||
| 286 | + | /// Insert a reply and bump `last_activity_at` on the caller's transaction. | |
| 287 | + | /// | |
| 288 | + | /// Lets a handler fold the reply *and its mentions* into one atomic unit so a | |
| 289 | + | /// mid-sequence failure can't leave a committed reply whose @mentions silently | |
| 290 | + | /// vanished (ultra-fuzz M-St1). The pool-taking [`create_post`] wraps this in | |
| 291 | + | /// its own transaction for the simple, mention-free callers (and tests). | |
| 292 | + | #[tracing::instrument(skip_all)] | |
| 293 | + | pub async fn create_post_tx( | |
| 294 | + | conn: &mut sqlx::PgConnection, | |
| 295 | + | thread_id: Uuid, | |
| 296 | + | author_id: Uuid, | |
| 297 | + | body_markdown: &str, | |
| 298 | + | body_html: &str, | |
| 299 | + | ) -> Result<Uuid, sqlx::Error> { | |
| 222 | 300 | let post_id = sqlx::query_scalar!( | |
| 223 | 301 | "INSERT INTO posts (thread_id, author_id, body_markdown, body_html) | |
| 224 | 302 | VALUES ($1, $2, $3, $4) | |
| @@ -228,14 +306,13 @@ pub async fn create_post( | |||
| 228 | 306 | body_markdown, | |
| 229 | 307 | body_html, | |
| 230 | 308 | ) | |
| 231 | - | .fetch_one(&mut *tx) | |
| 309 | + | .fetch_one(&mut *conn) | |
| 232 | 310 | .await?; | |
| 233 | 311 | ||
| 234 | 312 | sqlx::query!("UPDATE threads SET last_activity_at = now() WHERE id = $1", thread_id) | |
| 235 | - | .execute(&mut *tx) | |
| 313 | + | .execute(&mut *conn) | |
| 236 | 314 | .await?; | |
| 237 | 315 | ||
| 238 | - | tx.commit().await?; | |
| 239 | 316 | Ok(post_id) | |
| 240 | 317 | } | |
| 241 | 318 | ||
| @@ -989,8 +1066,22 @@ pub async fn set_thread_tags( | |||
| 989 | 1066 | tag_ids: &[Uuid], | |
| 990 | 1067 | ) -> Result<(), sqlx::Error> { | |
| 991 | 1068 | let mut tx = pool.begin().await?; | |
| 1069 | + | set_thread_tags_tx(&mut tx, thread_id, tag_ids).await?; | |
| 1070 | + | tx.commit().await?; | |
| 1071 | + | Ok(()) | |
| 1072 | + | } | |
| 1073 | + | ||
| 1074 | + | /// Set a thread's tags on the caller's transaction, so a handler can fold tag | |
| 1075 | + | /// assignment into the same atomic unit as the thread/OP creation (M-St1). The | |
| 1076 | + | /// pool-taking [`set_thread_tags`] wraps this in its own transaction. | |
| 1077 | + | #[tracing::instrument(skip_all)] | |
| 1078 | + | pub async fn set_thread_tags_tx( | |
| 1079 | + | conn: &mut sqlx::PgConnection, | |
| 1080 | + | thread_id: Uuid, | |
| 1081 | + | tag_ids: &[Uuid], | |
| 1082 | + | ) -> Result<(), sqlx::Error> { | |
| 992 | 1083 | sqlx::query!("DELETE FROM thread_tags WHERE thread_id = $1", thread_id) | |
| 993 | - | .execute(&mut *tx) | |
| 1084 | + | .execute(&mut *conn) | |
| 994 | 1085 | .await?; | |
| 995 | 1086 | if !tag_ids.is_empty() { | |
| 996 | 1087 | // runtime-checked: dynamic SQL (cannot use compile-time macro) | |
| @@ -1007,9 +1098,8 @@ pub async fn set_thread_tags( | |||
| 1007 | 1098 | for tag_id in tag_ids { | |
| 1008 | 1099 | query = query.bind(tag_id); | |
| 1009 | 1100 | } | |
| 1010 | - | query.execute(&mut *tx).await?; | |
| 1101 | + | query.execute(&mut *conn).await?; | |
| 1011 | 1102 | } | |
| 1012 | - | tx.commit().await?; | |
| 1013 | 1103 | Ok(()) | |
| 1014 | 1104 | } | |
| 1015 | 1105 | ||
| @@ -1113,8 +1203,8 @@ pub async fn insert_link_preview( | |||
| 1113 | 1203 | ||
| 1114 | 1204 | /// Insert mention rows for a post (batch insert). Ignores duplicates. | |
| 1115 | 1205 | #[tracing::instrument(skip_all)] | |
| 1116 | - | pub async fn insert_mentions( | |
| 1117 | - | pool: &PgPool, | |
| 1206 | + | pub async fn insert_mentions<'e, E: sqlx::PgExecutor<'e>>( | |
| 1207 | + | executor: E, | |
| 1118 | 1208 | post_id: Uuid, | |
| 1119 | 1209 | user_ids: &[Uuid], | |
| 1120 | 1210 | ) -> Result<(), sqlx::Error> { | |
| @@ -1135,7 +1225,7 @@ pub async fn insert_mentions( | |||
| 1135 | 1225 | for user_id in user_ids { | |
| 1136 | 1226 | query = query.bind(user_id); | |
| 1137 | 1227 | } | |
| 1138 | - | query.execute(pool).await?; | |
| 1228 | + | query.execute(executor).await?; | |
| 1139 | 1229 | Ok(()) | |
| 1140 | 1230 | } | |
| 1141 | 1231 |
| @@ -624,22 +624,35 @@ pub async fn callback( | |||
| 624 | 624 | // Upsert local user. `is_fan_plus`/`is_creator` are denormalised here so | |
| 625 | 625 | // post rendering can look up the post author's perks via JOIN — see | |
| 626 | 626 | // migration 026. | |
| 627 | - | let upsert_result = sqlx::query( | |
| 628 | - | r#" | |
| 629 | - | INSERT INTO users (mnw_account_id, username, display_name, avatar_url, is_fan_plus, is_creator) | |
| 630 | - | VALUES ($1, $2, $3, $4, $5, $6) | |
| 631 | - | ON CONFLICT (mnw_account_id) DO UPDATE | |
| 632 | - | SET username = $2, display_name = $3, avatar_url = $4, | |
| 633 | - | is_fan_plus = $5, is_creator = $6, updated_at = now() | |
| 634 | - | "#, | |
| 635 | - | ) | |
| 636 | - | .bind(info.user_id) | |
| 637 | - | .bind(&info.username) | |
| 638 | - | .bind(&info.display_name) | |
| 639 | - | .bind(&info.avatar_url) | |
| 640 | - | .bind(info.perks.fan_plus) | |
| 641 | - | .bind(info.perks.is_creator) | |
| 642 | - | .execute(&state.db) | |
| 627 | + | // | |
| 628 | + | // First vacate the username from any stale mirror row that still holds it: | |
| 629 | + | // MNW usernames are mutable/reusable, so a login that renames into a name an | |
| 630 | + | // old row carries would otherwise trip the `users_username_key` unique index | |
| 631 | + | // and lock the user out (S2). The vacate and the upsert run on one | |
| 632 | + | // transaction so the freed name is visible to the insert. | |
| 633 | + | let upsert_result = async { | |
| 634 | + | let mut tx = state.db.begin().await?; | |
| 635 | + | mt_db::mutations::vacate_username_for_login(&mut tx, info.user_id, &info.username).await?; | |
| 636 | + | sqlx::query( | |
| 637 | + | r#" | |
| 638 | + | INSERT INTO users (mnw_account_id, username, display_name, avatar_url, is_fan_plus, is_creator) | |
| 639 | + | VALUES ($1, $2, $3, $4, $5, $6) | |
| 640 | + | ON CONFLICT (mnw_account_id) DO UPDATE | |
| 641 | + | SET username = $2, display_name = $3, avatar_url = $4, | |
| 642 | + | is_fan_plus = $5, is_creator = $6, updated_at = now() | |
| 643 | + | "#, | |
| 644 | + | ) | |
| 645 | + | .bind(info.user_id) | |
| 646 | + | .bind(&info.username) | |
| 647 | + | .bind(&info.display_name) | |
| 648 | + | .bind(&info.avatar_url) | |
| 649 | + | .bind(info.perks.fan_plus) | |
| 650 | + | .bind(info.perks.is_creator) | |
| 651 | + | .execute(&mut *tx) | |
| 652 | + | .await?; | |
| 653 | + | tx.commit().await?; | |
| 654 | + | Ok::<(), sqlx::Error>(()) | |
| 655 | + | } | |
| 643 | 656 | .await; | |
| 644 | 657 | ||
| 645 | 658 | if let Err(e) = upsert_result { |
| @@ -282,43 +282,44 @@ pub(in crate::routes) async fn create_thread_handler( | |||
| 282 | 282 | &state.db, body, community.id, &slug, user.user_id, author_plus, | |
| 283 | 283 | ).await?; | |
| 284 | 284 | ||
| 285 | - | let (thread_id, post_id) = | |
| 286 | - | mt_db::mutations::create_thread_with_op(&state.db, category_id, user.user_id, title, body, &body_html) | |
| 287 | - | .await | |
| 288 | - | .map_err(|e| { | |
| 289 | - | tracing::error!(error = ?e, "db error creating thread"); | |
| 290 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 291 | - | })?; | |
| 285 | + | let tag_ids: Vec<uuid::Uuid> = form | |
| 286 | + | .tags | |
| 287 | + | .iter() | |
| 288 | + | .filter_map(|t| uuid::Uuid::parse_str(t).ok()) | |
| 289 | + | .collect(); | |
| 292 | 290 | ||
| 293 | - | if !mention_ids.is_empty() { | |
| 294 | - | mt_db::mutations::insert_mentions(&state.db, post_id, &mention_ids) | |
| 291 | + | // Create the thread, its opening post, its @mentions, and its tags as one | |
| 292 | + | // atomic unit. Previously these committed in separate statements, so a | |
| 293 | + | // failure after the thread insert silently dropped the mentions (no | |
| 294 | + | // notification) and tags (M-St1). | |
| 295 | + | let mut tx = begin_tx(&state.db).await?; | |
| 296 | + | let (thread_id, post_id) = mt_db::mutations::create_thread_with_op_tx( | |
| 297 | + | &mut tx, category_id, user.user_id, title, body, &body_html, | |
| 298 | + | ) | |
| 299 | + | .await | |
| 300 | + | .map_err(|e| { | |
| 301 | + | tracing::error!(error = ?e, "db error creating thread"); | |
| 302 | + | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 303 | + | })?; | |
| 304 | + | mt_db::mutations::insert_mentions(&mut *tx, post_id, &mention_ids) | |
| 305 | + | .await | |
| 306 | + | .map_err(|e| { | |
| 307 | + | tracing::error!(error = ?e, "db error inserting mentions"); | |
| 308 | + | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 309 | + | })?; | |
| 310 | + | if !tag_ids.is_empty() { | |
| 311 | + | mt_db::mutations::set_thread_tags_tx(&mut tx, thread_id, &tag_ids) | |
| 295 | 312 | .await | |
| 296 | 313 | .map_err(|e| { | |
| 297 | - | tracing::error!(error = ?e, "db error inserting mentions"); | |
| 314 | + | tracing::error!(error = ?e, "db error setting thread tags"); | |
| 298 | 315 | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 299 | 316 | })?; | |
| 300 | 317 | } | |
| 318 | + | commit_tx(tx).await?; | |
| 301 | 319 | ||
| 302 | 320 | // Fetch link previews in background (best-effort, never blocks response) | |
| 303 | 321 | spawn_link_preview_fetch(state.clone(), body.to_string(), post_id); | |
| 304 | 322 | ||
| 305 | - | // Save tags if any were selected | |
| 306 | - | if !form.tags.is_empty() { | |
| 307 | - | let tag_ids: Vec<uuid::Uuid> = form | |
| 308 | - | .tags | |
| 309 | - | .iter() | |
| 310 | - | .filter_map(|t| uuid::Uuid::parse_str(t).ok()) | |
| 311 | - | .collect(); | |
| 312 | - | if !tag_ids.is_empty() { | |
| 313 | - | mt_db::mutations::set_thread_tags(&state.db, thread_id, &tag_ids) | |
| 314 | - | .await | |
| 315 | - | .map_err(|e| { | |
| 316 | - | tracing::error!(error = ?e, "db error setting thread tags"); | |
| 317 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 318 | - | })?; | |
| 319 | - | } | |
| 320 | - | } | |
| 321 | - | ||
| 322 | 323 | Ok(Redirect::to(&format!( | |
| 323 | 324 | "/p/{slug}/{category_slug}/{thread_id}?toast=Thread+created" | |
| 324 | 325 | ))) | |
| @@ -365,21 +366,23 @@ pub(in crate::routes) async fn create_reply_handler( | |||
| 365 | 366 | ).await?; | |
| 366 | 367 | ||
| 367 | 368 | let thread_id = parse_uuid(&thread_id_str)?; | |
| 368 | - | let post_id = mt_db::mutations::create_post(&state.db, thread_id, user.user_id, body, &body_html) | |
| 369 | + | ||
| 370 | + | // Create the reply and its @mentions as one atomic unit so a failure between | |
| 371 | + | // them can't leave a committed reply whose mentions silently vanished (M-St1). | |
| 372 | + | let mut tx = begin_tx(&state.db).await?; | |
| 373 | + | let post_id = mt_db::mutations::create_post_tx(&mut tx, thread_id, user.user_id, body, &body_html) | |
| 369 | 374 | .await | |
| 370 | 375 | .map_err(|e| { | |
| 371 | 376 | tracing::error!(error = ?e, "db error creating reply"); | |
| 372 | 377 | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 373 | 378 | })?; | |
| 374 | - | ||
| 375 | - | if !mention_ids.is_empty() { | |
| 376 | - | mt_db::mutations::insert_mentions(&state.db, post_id, &mention_ids) | |
| 377 | - | .await | |
| 378 | - | .map_err(|e| { | |
| 379 | - | tracing::error!(error = ?e, "db error inserting mentions"); | |
| 380 | - | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 381 | - | })?; | |
| 382 | - | } | |
| 379 | + | mt_db::mutations::insert_mentions(&mut *tx, post_id, &mention_ids) | |
| 380 | + | .await | |
| 381 | + | .map_err(|e| { | |
| 382 | + | tracing::error!(error = ?e, "db error inserting mentions"); | |
| 383 | + | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 384 | + | })?; | |
| 385 | + | commit_tx(tx).await?; | |
| 383 | 386 | ||
| 384 | 387 | // Fetch link previews in background (best-effort, never blocks response) | |
| 385 | 388 | spawn_link_preview_fetch(state.clone(), body.to_string(), post_id); |
| @@ -880,3 +880,57 @@ async fn s3_purge_sweep_targets_only_removed_unpurged_images() { | |||
| 880 | 880 | .await | |
| 881 | 881 | .unwrap(); | |
| 882 | 882 | } | |
| 883 | + | ||
| 884 | + | // ============================================================================ | |
| 885 | + | // Username-collision reconciliation (ultra-fuzz S2) | |
| 886 | + | // ============================================================================ | |
| 887 | + | ||
| 888 | + | /// A login that renames into a username a *stale* mirror row still holds must | |
| 889 | + | /// succeed, not 23505-lockout. The stale row yields the name (MNW is the source | |
| 890 | + | /// of truth; usernames are reusable there) and keeps its data under a | |
| 891 | + | /// collision-proof `mnw_<account-id>` placeholder. | |
| 892 | + | #[tokio::test] | |
| 893 | + | async fn upsert_user_reclaims_username_from_stale_row() { | |
| 894 | + | let h = TestHarness::new().await; | |
| 895 | + | let old = Uuid::new_v4(); | |
| 896 | + | let new = Uuid::new_v4(); | |
| 897 | + | ||
| 898 | + | // `old` currently owns "shared". | |
| 899 | + | mt_db::mutations::upsert_user(&h.db, old, "shared", None).await.unwrap(); | |
| 900 | + | ||
| 901 | + | // `new` logs in having taken the name "shared" upstream. This must not error. | |
| 902 | + | mt_db::mutations::upsert_user(&h.db, new, "shared", None) | |
| 903 | + | .await | |
| 904 | + | .expect("rename into a name a stale row holds must not lock the user out"); | |
| 905 | + | ||
| 906 | + | // `new` now owns "shared"; `old` was vacated to its placeholder. | |
| 907 | + | let owner: Uuid = sqlx::query_scalar("SELECT mnw_account_id FROM users WHERE username = 'shared'") | |
| 908 | + | .fetch_one(&h.db) | |
| 909 | + | .await | |
| 910 | + | .unwrap(); | |
| 911 | + | assert_eq!(owner, new, "the current login must own the reused username"); | |
| 912 | + | ||
| 913 | + | let old_name: String = sqlx::query_scalar("SELECT username FROM users WHERE mnw_account_id = $1") | |
| 914 | + | .bind(old) | |
| 915 | + | .fetch_one(&h.db) | |
| 916 | + | .await | |
| 917 | + | .unwrap(); | |
| 918 | + | assert_eq!(old_name, format!("mnw_{old}"), "stale row keeps its data under a placeholder name"); | |
| 919 | + | } | |
| 920 | + | ||
| 921 | + | /// Re-upserting the same account with its own unchanged username is a no-op rename | |
| 922 | + | /// (the `mnw_account_id <> $2` guard skips self), not a self-collision. | |
| 923 | + | #[tokio::test] | |
| 924 | + | async fn upsert_user_same_account_same_name_is_stable() { | |
| 925 | + | let h = TestHarness::new().await; | |
| 926 | + | let acct = Uuid::new_v4(); | |
| 927 | + | mt_db::mutations::upsert_user(&h.db, acct, "stable", Some("Display")).await.unwrap(); | |
| 928 | + | mt_db::mutations::upsert_user(&h.db, acct, "stable", Some("Display Two")).await.unwrap(); | |
| 929 | + | ||
| 930 | + | let name: String = sqlx::query_scalar("SELECT username FROM users WHERE mnw_account_id = $1") | |
| 931 | + | .bind(acct) | |
| 932 | + | .fetch_one(&h.db) | |
| 933 | + | .await | |
| 934 | + | .unwrap(); | |
| 935 | + | assert_eq!(name, "stable"); | |
| 936 | + | } |