Skip to main content

max / makenotwork

8.0 KB · 241 lines History Blame Raw
1 //! DB-layer contract tests for `db::collections`, user-curated item lists.
2 //!
3 //! Audit Run 16 flagged `collections` as a Concurrency/Testing cold spot (B+).
4 //! These pin the append/idempotency/reorder contract of `collection_items` at
5 //! the DB layer: `add_item_to_collection` appends at `MAX(position)+1` and is
6 //! idempotent via `ON CONFLICT (collection_id, item_id) DO NOTHING`; reorder
7 //! reassigns positions atomically; the slug-clash "seal" rejects a duplicate as
8 //! a clean validation error rather than a raw 23505; and concurrent adds of
9 //! distinct items each land exactly once (no lost writes).
10 //!
11 //! NOTE: `add_item_to_collection` computes `MAX(position)+1` outside a lock, so
12 //! two concurrent adds of *different* items can tie on a position (ordering
13 //! ambiguity, not data loss). The concurrency test asserts the guaranteed
14 //! invariant, every item is present exactly once, not position distinctness.
15
16 use crate::harness::db::TestDb;
17 use makenotwork::db::{ItemId, ProjectId, Slug, UserId, collections};
18
19 async fn seed_user(pool: &sqlx::PgPool, username: &str) -> UserId {
20 let hash = makenotwork::auth::hash_password("password123").expect("hash");
21 sqlx::query_scalar::<_, UserId>(
22 "INSERT INTO users (username, email, password_hash, email_verified)
23 VALUES ($1, $2, $3, true) RETURNING id",
24 )
25 .bind(username)
26 .bind(format!("{username}@test.com"))
27 .bind(&hash)
28 .fetch_one(pool)
29 .await
30 .expect("seed user")
31 }
32
33 async fn seed_project(pool: &sqlx::PgPool, user: UserId, slug: &str) -> ProjectId {
34 sqlx::query_scalar::<_, ProjectId>(
35 "INSERT INTO projects (user_id, slug, title) VALUES ($1, $2, 'P') RETURNING id",
36 )
37 .bind(user)
38 .bind(slug)
39 .fetch_one(pool)
40 .await
41 .expect("seed project")
42 }
43
44 async fn seed_item(pool: &sqlx::PgPool, project: ProjectId, slug: &str) -> ItemId {
45 sqlx::query_scalar::<_, ItemId>(
46 "INSERT INTO items (project_id, title, item_type, price_cents, slug)
47 VALUES ($1, $2, 'digital', 1000, $3) RETURNING id",
48 )
49 .bind(project)
50 .bind(format!("Item {slug}"))
51 .bind(slug)
52 .fetch_one(pool)
53 .await
54 .expect("seed item")
55 }
56
57 fn slug(s: &str) -> Slug {
58 Slug::from_trusted(s.to_string())
59 }
60
61 #[tokio::test]
62 async fn add_item_appends_positions_and_is_idempotent() {
63 let db = TestDb::new().await;
64 let user = seed_user(&db.pool, "col_add").await;
65 let project = seed_project(&db.pool, user, "col-add").await;
66 let col = collections::create_collection(&db.pool, user, &slug("faves"), "Faves", None, true)
67 .await
68 .unwrap();
69 let a = seed_item(&db.pool, project, "a").await;
70 let b = seed_item(&db.pool, project, "b").await;
71
72 collections::add_item_to_collection(&db.pool, col.id, a)
73 .await
74 .unwrap();
75 collections::add_item_to_collection(&db.pool, col.id, b)
76 .await
77 .unwrap();
78 // Re-adding an existing item is a no-op (ON CONFLICT), not a second row.
79 collections::add_item_to_collection(&db.pool, col.id, a)
80 .await
81 .unwrap();
82
83 assert_eq!(
84 collections::count_collection_items(&db.pool, col.id)
85 .await
86 .unwrap(),
87 2
88 );
89 let items = collections::get_collection_items(&db.pool, col.id)
90 .await
91 .unwrap();
92 // Serial appends take sequential positions starting at 0.
93 assert_eq!(
94 items.iter().map(|r| r.position).collect::<Vec<_>>(),
95 vec![0, 1]
96 );
97 assert_eq!(items[0].item_id, a);
98 assert_eq!(items[1].item_id, b);
99 }
100
101 #[tokio::test]
102 async fn remove_item_reports_whether_a_row_was_deleted() {
103 let db = TestDb::new().await;
104 let user = seed_user(&db.pool, "col_rm").await;
105 let project = seed_project(&db.pool, user, "col-rm").await;
106 let col = collections::create_collection(&db.pool, user, &slug("rm"), "Rm", None, false)
107 .await
108 .unwrap();
109 let item = seed_item(&db.pool, project, "r").await;
110 collections::add_item_to_collection(&db.pool, col.id, item)
111 .await
112 .unwrap();
113
114 assert!(
115 collections::remove_item_from_collection(&db.pool, col.id, item)
116 .await
117 .unwrap()
118 );
119 // Removing again finds nothing.
120 assert!(
121 !collections::remove_item_from_collection(&db.pool, col.id, item)
122 .await
123 .unwrap()
124 );
125 assert_eq!(
126 collections::count_collection_items(&db.pool, col.id)
127 .await
128 .unwrap(),
129 0
130 );
131 }
132
133 #[tokio::test]
134 async fn reorder_reassigns_positions_by_the_given_sequence() {
135 let db = TestDb::new().await;
136 let user = seed_user(&db.pool, "col_reorder").await;
137 let project = seed_project(&db.pool, user, "col-reorder").await;
138 let col = collections::create_collection(&db.pool, user, &slug("ord"), "Ord", None, true)
139 .await
140 .unwrap();
141 let a = seed_item(&db.pool, project, "oa").await;
142 let b = seed_item(&db.pool, project, "ob").await;
143 let c = seed_item(&db.pool, project, "oc").await;
144 for it in [a, b, c] {
145 collections::add_item_to_collection(&db.pool, col.id, it)
146 .await
147 .unwrap();
148 }
149
150 // Reverse the order: c, b, a -> positions 0, 1, 2.
151 collections::reorder_collection_items(&db.pool, col.id, &[c, b, a])
152 .await
153 .unwrap();
154
155 let items = collections::get_collection_items(&db.pool, col.id)
156 .await
157 .unwrap();
158 assert_eq!(
159 items.iter().map(|r| r.item_id).collect::<Vec<_>>(),
160 vec![c, b, a],
161 "get_collection_items returns items in the reordered sequence"
162 );
163 assert_eq!(
164 items.iter().map(|r| r.position).collect::<Vec<_>>(),
165 vec![0, 1, 2]
166 );
167 }
168
169 /// The slug "seal": a per-user duplicate slug is mapped to a clean validation
170 /// error, never a raw 23505 bubbling to a 500.
171 #[tokio::test]
172 async fn duplicate_slug_is_a_clean_validation_error() {
173 let db = TestDb::new().await;
174 let user = seed_user(&db.pool, "col_slug").await;
175 collections::create_collection(&db.pool, user, &slug("dup"), "First", None, true)
176 .await
177 .unwrap();
178
179 let clash =
180 collections::create_collection(&db.pool, user, &slug("dup"), "Second", None, true).await;
181 assert!(
182 matches!(
183 clash,
184 Err(makenotwork::error::AppError::BadRequest(_)
185 | makenotwork::error::AppError::Validation(_))
186 ),
187 "a duplicate slug must surface as a validation error, got {clash:?}"
188 );
189 }
190
191 /// Concurrent adds of distinct items must not lose a write: every item lands in
192 /// the collection exactly once. (Positions may tie under the race, see the
193 /// module note, so this asserts membership, the invariant that actually holds.)
194 #[tokio::test]
195 async fn concurrent_adds_of_distinct_items_all_land_once() {
196 let db = TestDb::new().await;
197 let user = seed_user(&db.pool, "col_race").await;
198 let project = seed_project(&db.pool, user, "col-race").await;
199 let col = collections::create_collection(&db.pool, user, &slug("race"), "Race", None, true)
200 .await
201 .unwrap();
202
203 let mut item_ids = Vec::new();
204 for i in 0..6 {
205 item_ids.push(seed_item(&db.pool, project, &format!("rc{i}")).await);
206 }
207
208 let mut handles = Vec::new();
209 for item in item_ids.iter().copied() {
210 let pool = db.pool.clone();
211 let cid = col.id;
212 handles.push(tokio::spawn(async move {
213 collections::add_item_to_collection(&pool, cid, item).await
214 }));
215 }
216 for h in handles {
217 h.await
218 .expect("task panicked")
219 .expect("concurrent add must not error");
220 }
221
222 assert_eq!(
223 collections::count_collection_items(&db.pool, col.id)
224 .await
225 .unwrap(),
226 6
227 );
228 let present: std::collections::HashSet<_> = collections::get_collection_items(&db.pool, col.id)
229 .await
230 .unwrap()
231 .into_iter()
232 .map(|r| r.item_id)
233 .collect();
234 for item in item_ids {
235 assert!(
236 present.contains(&item),
237 "every concurrently-added item is present exactly once"
238 );
239 }
240 }
241