//! DB-layer contract tests for `db::collections`, user-curated item lists. //! //! Audit Run 16 flagged `collections` as a Concurrency/Testing cold spot (B+). //! These pin the append/idempotency/reorder contract of `collection_items` at //! the DB layer: `add_item_to_collection` appends at `MAX(position)+1` and is //! idempotent via `ON CONFLICT (collection_id, item_id) DO NOTHING`; reorder //! reassigns positions atomically; the slug-clash "seal" rejects a duplicate as //! a clean validation error rather than a raw 23505; and concurrent adds of //! distinct items each land exactly once (no lost writes). //! //! NOTE: `add_item_to_collection` computes `MAX(position)+1` outside a lock, so //! two concurrent adds of *different* items can tie on a position (ordering //! ambiguity, not data loss). The concurrency test asserts the guaranteed //! invariant, every item is present exactly once, not position distinctness. use crate::harness::db::TestDb; use makenotwork::db::{ItemId, ProjectId, Slug, UserId, collections}; async fn seed_user(pool: &sqlx::PgPool, username: &str) -> UserId { let hash = makenotwork::auth::hash_password("password123").expect("hash"); sqlx::query_scalar::<_, UserId>( "INSERT INTO users (username, email, password_hash, email_verified) VALUES ($1, $2, $3, true) RETURNING id", ) .bind(username) .bind(format!("{username}@test.com")) .bind(&hash) .fetch_one(pool) .await .expect("seed user") } async fn seed_project(pool: &sqlx::PgPool, user: UserId, slug: &str) -> ProjectId { sqlx::query_scalar::<_, ProjectId>( "INSERT INTO projects (user_id, slug, title) VALUES ($1, $2, 'P') RETURNING id", ) .bind(user) .bind(slug) .fetch_one(pool) .await .expect("seed project") } async fn seed_item(pool: &sqlx::PgPool, project: ProjectId, slug: &str) -> ItemId { sqlx::query_scalar::<_, ItemId>( "INSERT INTO items (project_id, title, item_type, price_cents, slug) VALUES ($1, $2, 'digital', 1000, $3) RETURNING id", ) .bind(project) .bind(format!("Item {slug}")) .bind(slug) .fetch_one(pool) .await .expect("seed item") } fn slug(s: &str) -> Slug { Slug::from_trusted(s.to_string()) } #[tokio::test] async fn add_item_appends_positions_and_is_idempotent() { let db = TestDb::new().await; let user = seed_user(&db.pool, "col_add").await; let project = seed_project(&db.pool, user, "col-add").await; let col = collections::create_collection(&db.pool, user, &slug("faves"), "Faves", None, true) .await .unwrap(); let a = seed_item(&db.pool, project, "a").await; let b = seed_item(&db.pool, project, "b").await; collections::add_item_to_collection(&db.pool, col.id, a) .await .unwrap(); collections::add_item_to_collection(&db.pool, col.id, b) .await .unwrap(); // Re-adding an existing item is a no-op (ON CONFLICT), not a second row. collections::add_item_to_collection(&db.pool, col.id, a) .await .unwrap(); assert_eq!( collections::count_collection_items(&db.pool, col.id) .await .unwrap(), 2 ); let items = collections::get_collection_items(&db.pool, col.id) .await .unwrap(); // Serial appends take sequential positions starting at 0. assert_eq!( items.iter().map(|r| r.position).collect::>(), vec![0, 1] ); assert_eq!(items[0].item_id, a); assert_eq!(items[1].item_id, b); } #[tokio::test] async fn remove_item_reports_whether_a_row_was_deleted() { let db = TestDb::new().await; let user = seed_user(&db.pool, "col_rm").await; let project = seed_project(&db.pool, user, "col-rm").await; let col = collections::create_collection(&db.pool, user, &slug("rm"), "Rm", None, false) .await .unwrap(); let item = seed_item(&db.pool, project, "r").await; collections::add_item_to_collection(&db.pool, col.id, item) .await .unwrap(); assert!( collections::remove_item_from_collection(&db.pool, col.id, item) .await .unwrap() ); // Removing again finds nothing. assert!( !collections::remove_item_from_collection(&db.pool, col.id, item) .await .unwrap() ); assert_eq!( collections::count_collection_items(&db.pool, col.id) .await .unwrap(), 0 ); } #[tokio::test] async fn reorder_reassigns_positions_by_the_given_sequence() { let db = TestDb::new().await; let user = seed_user(&db.pool, "col_reorder").await; let project = seed_project(&db.pool, user, "col-reorder").await; let col = collections::create_collection(&db.pool, user, &slug("ord"), "Ord", None, true) .await .unwrap(); let a = seed_item(&db.pool, project, "oa").await; let b = seed_item(&db.pool, project, "ob").await; let c = seed_item(&db.pool, project, "oc").await; for it in [a, b, c] { collections::add_item_to_collection(&db.pool, col.id, it) .await .unwrap(); } // Reverse the order: c, b, a -> positions 0, 1, 2. collections::reorder_collection_items(&db.pool, col.id, &[c, b, a]) .await .unwrap(); let items = collections::get_collection_items(&db.pool, col.id) .await .unwrap(); assert_eq!( items.iter().map(|r| r.item_id).collect::>(), vec![c, b, a], "get_collection_items returns items in the reordered sequence" ); assert_eq!( items.iter().map(|r| r.position).collect::>(), vec![0, 1, 2] ); } /// The slug "seal": a per-user duplicate slug is mapped to a clean validation /// error, never a raw 23505 bubbling to a 500. #[tokio::test] async fn duplicate_slug_is_a_clean_validation_error() { let db = TestDb::new().await; let user = seed_user(&db.pool, "col_slug").await; collections::create_collection(&db.pool, user, &slug("dup"), "First", None, true) .await .unwrap(); let clash = collections::create_collection(&db.pool, user, &slug("dup"), "Second", None, true).await; assert!( matches!( clash, Err(makenotwork::error::AppError::BadRequest(_) | makenotwork::error::AppError::Validation(_)) ), "a duplicate slug must surface as a validation error, got {clash:?}" ); } /// Concurrent adds of distinct items must not lose a write: every item lands in /// the collection exactly once. (Positions may tie under the race, see the /// module note, so this asserts membership, the invariant that actually holds.) #[tokio::test] async fn concurrent_adds_of_distinct_items_all_land_once() { let db = TestDb::new().await; let user = seed_user(&db.pool, "col_race").await; let project = seed_project(&db.pool, user, "col-race").await; let col = collections::create_collection(&db.pool, user, &slug("race"), "Race", None, true) .await .unwrap(); let mut item_ids = Vec::new(); for i in 0..6 { item_ids.push(seed_item(&db.pool, project, &format!("rc{i}")).await); } let mut handles = Vec::new(); for item in item_ids.iter().copied() { let pool = db.pool.clone(); let cid = col.id; handles.push(tokio::spawn(async move { collections::add_item_to_collection(&pool, cid, item).await })); } for h in handles { h.await .expect("task panicked") .expect("concurrent add must not error"); } assert_eq!( collections::count_collection_items(&db.pool, col.id) .await .unwrap(), 6 ); let present: std::collections::HashSet<_> = collections::get_collection_items(&db.pool, col.id) .await .unwrap() .into_iter() .map(|r| r.item_id) .collect(); for item in item_ids { assert!( present.contains(&item), "every concurrently-added item is present exactly once" ); } }