Skip to main content

max / makenotwork

7.3 KB · 217 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 crate::harness::{seed_project, seed_user};
18 use makenotwork::db::{ItemId, ProjectId, Slug, collections};
19
20 async fn seed_item(pool: &sqlx::PgPool, project: ProjectId, slug: &str) -> ItemId {
21 sqlx::query_scalar::<_, ItemId>(
22 "INSERT INTO items (project_id, title, item_type, price_cents, slug)
23 VALUES ($1, $2, 'digital', 1000, $3) RETURNING id",
24 )
25 .bind(project)
26 .bind(format!("Item {slug}"))
27 .bind(slug)
28 .fetch_one(pool)
29 .await
30 .expect("seed item")
31 }
32
33 fn slug(s: &str) -> Slug {
34 Slug::from_trusted(s.to_string())
35 }
36
37 #[tokio::test]
38 async fn add_item_appends_positions_and_is_idempotent() {
39 let db = TestDb::new().await;
40 let user = seed_user(&db.pool, "col_add").await;
41 let project = seed_project(&db.pool, user, "col-add").await;
42 let col = collections::create_collection(&db.pool, user, &slug("faves"), "Faves", None, true)
43 .await
44 .unwrap();
45 let a = seed_item(&db.pool, project, "a").await;
46 let b = seed_item(&db.pool, project, "b").await;
47
48 collections::add_item_to_collection(&db.pool, col.id, a)
49 .await
50 .unwrap();
51 collections::add_item_to_collection(&db.pool, col.id, b)
52 .await
53 .unwrap();
54 // Re-adding an existing item is a no-op (ON CONFLICT), not a second row.
55 collections::add_item_to_collection(&db.pool, col.id, a)
56 .await
57 .unwrap();
58
59 assert_eq!(
60 collections::count_collection_items(&db.pool, col.id)
61 .await
62 .unwrap(),
63 2
64 );
65 let items = collections::get_collection_items(&db.pool, col.id)
66 .await
67 .unwrap();
68 // Serial appends take sequential positions starting at 0.
69 assert_eq!(
70 items.iter().map(|r| r.position).collect::<Vec<_>>(),
71 vec![0, 1]
72 );
73 assert_eq!(items[0].item_id, a);
74 assert_eq!(items[1].item_id, b);
75 }
76
77 #[tokio::test]
78 async fn remove_item_reports_whether_a_row_was_deleted() {
79 let db = TestDb::new().await;
80 let user = seed_user(&db.pool, "col_rm").await;
81 let project = seed_project(&db.pool, user, "col-rm").await;
82 let col = collections::create_collection(&db.pool, user, &slug("rm"), "Rm", None, false)
83 .await
84 .unwrap();
85 let item = seed_item(&db.pool, project, "r").await;
86 collections::add_item_to_collection(&db.pool, col.id, item)
87 .await
88 .unwrap();
89
90 assert!(
91 collections::remove_item_from_collection(&db.pool, col.id, item)
92 .await
93 .unwrap()
94 );
95 // Removing again finds nothing.
96 assert!(
97 !collections::remove_item_from_collection(&db.pool, col.id, item)
98 .await
99 .unwrap()
100 );
101 assert_eq!(
102 collections::count_collection_items(&db.pool, col.id)
103 .await
104 .unwrap(),
105 0
106 );
107 }
108
109 #[tokio::test]
110 async fn reorder_reassigns_positions_by_the_given_sequence() {
111 let db = TestDb::new().await;
112 let user = seed_user(&db.pool, "col_reorder").await;
113 let project = seed_project(&db.pool, user, "col-reorder").await;
114 let col = collections::create_collection(&db.pool, user, &slug("ord"), "Ord", None, true)
115 .await
116 .unwrap();
117 let a = seed_item(&db.pool, project, "oa").await;
118 let b = seed_item(&db.pool, project, "ob").await;
119 let c = seed_item(&db.pool, project, "oc").await;
120 for it in [a, b, c] {
121 collections::add_item_to_collection(&db.pool, col.id, it)
122 .await
123 .unwrap();
124 }
125
126 // Reverse the order: c, b, a -> positions 0, 1, 2.
127 collections::reorder_collection_items(&db.pool, col.id, &[c, b, a])
128 .await
129 .unwrap();
130
131 let items = collections::get_collection_items(&db.pool, col.id)
132 .await
133 .unwrap();
134 assert_eq!(
135 items.iter().map(|r| r.item_id).collect::<Vec<_>>(),
136 vec![c, b, a],
137 "get_collection_items returns items in the reordered sequence"
138 );
139 assert_eq!(
140 items.iter().map(|r| r.position).collect::<Vec<_>>(),
141 vec![0, 1, 2]
142 );
143 }
144
145 /// The slug "seal": a per-user duplicate slug is mapped to a clean validation
146 /// error, never a raw 23505 bubbling to a 500.
147 #[tokio::test]
148 async fn duplicate_slug_is_a_clean_validation_error() {
149 let db = TestDb::new().await;
150 let user = seed_user(&db.pool, "col_slug").await;
151 collections::create_collection(&db.pool, user, &slug("dup"), "First", None, true)
152 .await
153 .unwrap();
154
155 let clash =
156 collections::create_collection(&db.pool, user, &slug("dup"), "Second", None, true).await;
157 assert!(
158 matches!(
159 clash,
160 Err(makenotwork::error::AppError::BadRequest(_)
161 | makenotwork::error::AppError::Validation(_))
162 ),
163 "a duplicate slug must surface as a validation error, got {clash:?}"
164 );
165 }
166
167 /// Concurrent adds of distinct items must not lose a write: every item lands in
168 /// the collection exactly once. (Positions may tie under the race, see the
169 /// module note, so this asserts membership, the invariant that actually holds.)
170 #[tokio::test]
171 async fn concurrent_adds_of_distinct_items_all_land_once() {
172 let db = TestDb::new().await;
173 let user = seed_user(&db.pool, "col_race").await;
174 let project = seed_project(&db.pool, user, "col-race").await;
175 let col = collections::create_collection(&db.pool, user, &slug("race"), "Race", None, true)
176 .await
177 .unwrap();
178
179 let mut item_ids = Vec::new();
180 for i in 0..6 {
181 item_ids.push(seed_item(&db.pool, project, &format!("rc{i}")).await);
182 }
183
184 let mut handles = Vec::new();
185 for item in item_ids.iter().copied() {
186 let pool = db.pool.clone();
187 let cid = col.id;
188 handles.push(tokio::spawn(async move {
189 collections::add_item_to_collection(&pool, cid, item).await
190 }));
191 }
192 for h in handles {
193 h.await
194 .expect("task panicked")
195 .expect("concurrent add must not error");
196 }
197
198 assert_eq!(
199 collections::count_collection_items(&db.pool, col.id)
200 .await
201 .unwrap(),
202 6
203 );
204 let present: std::collections::HashSet<_> = collections::get_collection_items(&db.pool, col.id)
205 .await
206 .unwrap()
207 .into_iter()
208 .map(|r| r.item_id)
209 .collect();
210 for item in item_ids {
211 assert!(
212 present.contains(&item),
213 "every concurrently-added item is present exactly once"
214 );
215 }
216 }
217