Skip to main content

max / makenotwork

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