Skip to main content

max / makenotwork

10.1 KB · 331 lines History Blame Raw
1 //! DB-layer contract tests for core `db::items` CRUD, the single-item side.
2 //!
3 //! The batch mutations have their own layer file
4 //! (`db_items_bulk_layer.rs`); these pin the single-item contracts it leaves
5 //! out: `create_item` + `get_item_by_id` round-trip (and the not-found `None`),
6 //! the denormalized `sales_count` increment/decrement (with the floor-at-zero
7 //! clamp), `update_item`'s publish toggle + ownership seal, the
8 //! post-grace hide/unhide round-trip, and the admin-removal republish block.
9
10 use crate::harness::TestHarness;
11 use crate::harness::{seed_project, seed_user};
12 use makenotwork::db::{AiTier, ItemId, ItemType, PriceCents, ProjectId, UserId, items};
13
14 /// Create a logged-in creator with an empty-ish project and return
15 /// `(user_id, project_id)`. (The helper also seeds one HTTP-created item, which
16 /// these tests ignore, they add their own items via `items::create_item`.)
17 async fn creator_project(h: &mut TestHarness, tag: &str) -> (UserId, ProjectId) {
18 let setup = h
19 .create_creator_with_item(&format!("itm_{tag}"), "digital", 1000)
20 .await;
21 let project_id: ProjectId = setup.project_id.parse().expect("project id parses");
22 (setup.user_id, project_id)
23 }
24
25 /// Insert a fresh item into a project via the real `create_item` path.
26 async fn make_item(h: &TestHarness, project: ProjectId, title: &str) -> ItemId {
27 let item = items::create_item(
28 &h.db,
29 project,
30 title,
31 None,
32 PriceCents::new(1000).expect("valid price"),
33 ItemType::Digital,
34 AiTier::Handmade,
35 None,
36 )
37 .await
38 .expect("create_item");
39 item.id
40 }
41
42 async fn is_public(h: &TestHarness, item: ItemId) -> bool {
43 sqlx::query_scalar::<_, bool>("SELECT is_public FROM items WHERE id = $1")
44 .bind(item)
45 .fetch_one(&h.db)
46 .await
47 .expect("read is_public")
48 }
49
50 async fn sales_count(h: &TestHarness, item: ItemId) -> i32 {
51 sqlx::query_scalar::<_, i32>("SELECT sales_count FROM items WHERE id = $1")
52 .bind(item)
53 .fetch_one(&h.db)
54 .await
55 .expect("read sales_count")
56 }
57
58 // ── create_item + get_item_by_id: round-trip and not-found ────────────────────
59
60 #[tokio::test]
61 async fn create_item_round_trips_through_get_by_id_and_missing_reads_none() {
62 let mut h = TestHarness::new().await;
63 let (_owner, project) = creator_project(&mut h, "get").await;
64
65 let created = items::create_item(
66 &h.db,
67 project,
68 "My First Track",
69 Some("a description"),
70 PriceCents::new(2500).expect("valid price"),
71 ItemType::Audio,
72 AiTier::Handmade,
73 None,
74 )
75 .await
76 .expect("create_item");
77
78 // create_item auto-generates a URL-safe slug from the title.
79 assert_eq!(
80 created.slug, "my-first-track",
81 "slug is derived from the title"
82 );
83
84 let fetched = items::get_item_by_id(&h.db, created.id)
85 .await
86 .expect("get_item_by_id ok")
87 .expect("the created item is found by id");
88 assert_eq!(fetched.id, created.id);
89 assert_eq!(fetched.title, "My First Track");
90 assert_eq!(
91 fetched.price_cents, 2500,
92 "stored price is the created 2500 cents"
93 );
94
95 // A random id that was never inserted reads as None, not an error.
96 let missing = items::get_item_by_id(&h.db, ItemId::new())
97 .await
98 .expect("get_item_by_id ok for a missing id");
99 assert!(missing.is_none(), "an unknown item id returns None");
100 }
101
102 // ── increment/decrement_sales_count: the denormalized counter ─────────────────
103
104 #[tokio::test]
105 async fn sales_count_increments_decrements_and_floors_at_zero() {
106 let mut h = TestHarness::new().await;
107 let (_owner, project) = creator_project(&mut h, "sales").await;
108 let item = make_item(&h, project, "Counter Item").await;
109
110 assert_eq!(
111 sales_count(&h, item).await,
112 0,
113 "a new item starts at zero sales"
114 );
115
116 items::increment_sales_count(&h.db, item)
117 .await
118 .expect("increment #1");
119 items::increment_sales_count(&h.db, item)
120 .await
121 .expect("increment #2");
122 assert_eq!(
123 sales_count(&h, item).await,
124 2,
125 "two increments bump the counter to 2"
126 );
127
128 items::decrement_sales_count(&h.db, item)
129 .await
130 .expect("decrement");
131 assert_eq!(
132 sales_count(&h, item).await,
133 1,
134 "a decrement (refund) walks it back"
135 );
136
137 // The clamp: decrementing past zero can't go negative (GREATEST(.., 0)).
138 items::decrement_sales_count(&h.db, item)
139 .await
140 .expect("decrement to zero");
141 items::decrement_sales_count(&h.db, item)
142 .await
143 .expect("decrement below zero is clamped");
144 assert_eq!(
145 sales_count(&h, item).await,
146 0,
147 "sales_count never goes negative"
148 );
149 }
150
151 // ── update_item: publish toggle + ownership seal ──────────────────────────────
152
153 #[tokio::test]
154 async fn update_item_toggles_publish_and_is_owner_scoped() {
155 let mut h = TestHarness::new().await;
156 let (owner, project) = creator_project(&mut h, "upd").await;
157 let attacker = h
158 .signup(
159 "itm_upd_attacker",
160 "itm_upd_attacker@test.com",
161 "password123",
162 )
163 .await;
164 let item = make_item(&h, project, "Toggle Me").await;
165
166 // Start unpublished, then the owner publishes via update_item.
167 items::update_item(
168 &h.db,
169 item,
170 owner,
171 None,
172 None,
173 None,
174 None,
175 Some(false),
176 None,
177 None,
178 None,
179 None,
180 None,
181 None,
182 )
183 .await
184 .expect("owner unpublish");
185 assert!(!is_public(&h, item).await);
186
187 // A non-owner update matches zero rows: the ownership subquery excludes them,
188 // so `fetch_one` finds nothing and the call errors, nothing changes.
189 let hijack = items::update_item(
190 &h.db,
191 item,
192 attacker,
193 None,
194 None,
195 None,
196 None,
197 Some(true),
198 None,
199 None,
200 None,
201 None,
202 None,
203 None,
204 )
205 .await;
206 assert!(
207 hijack.is_err(),
208 "a non-owner update_item must not touch the row"
209 );
210 assert!(
211 !is_public(&h, item).await,
212 "the item stays unpublished after the hijack attempt"
213 );
214
215 // The owner flips it public.
216 let published = items::update_item(
217 &h.db,
218 item,
219 owner,
220 None,
221 None,
222 None,
223 None,
224 Some(true),
225 None,
226 None,
227 None,
228 None,
229 None,
230 None,
231 )
232 .await
233 .expect("owner publish");
234 assert!(published.is_public, "the owner can publish their own item");
235 }
236
237 // ── hide/unhide_all_items_for_user: the post-grace round-trip ─────────────────
238
239 #[tokio::test]
240 async fn hide_then_unhide_all_items_for_user_round_trips() {
241 let h = TestHarness::new().await;
242 // Seed a contamination-free creator: `hide_all_items_for_users` spans every
243 // project the user owns, so the count must not include a stray HTTP-seeded item.
244 let owner = seed_user(&h.db, "itm_hide_owner").await;
245 let project = seed_project(&h.db, owner, "itm-hide-proj").await;
246 // create_item leaves is_public at its column default (true), so both start public.
247 let a = make_item(&h, project, "Public A").await;
248 let b = make_item(&h, project, "Public B").await;
249 assert!(
250 is_public(&h, a).await && is_public(&h, b).await,
251 "items start public"
252 );
253
254 // The scheduler sweep hides every public item for the creator in one shot.
255 let hidden = items::hide_all_items_for_users(&h.db, &[owner])
256 .await
257 .expect("hide");
258 assert_eq!(hidden, 2, "both public items are hidden");
259 assert!(!is_public(&h, a).await && !is_public(&h, b).await);
260
261 // An empty slice is a no-op, not a full-table update.
262 assert_eq!(
263 items::hide_all_items_for_users(&h.db, &[])
264 .await
265 .expect("empty hide"),
266 0
267 );
268
269 // Re-subscribing unhides them again and reports the count restored.
270 let unhidden = items::unhide_all_items_for_user(&h.db, owner)
271 .await
272 .expect("unhide");
273 assert_eq!(unhidden, 2, "both items are unhidden on re-subscribe");
274 assert!(is_public(&h, a).await && is_public(&h, b).await);
275 }
276
277 // ── admin_remove_item: hides + blocks creator republish ───────────────────────
278
279 #[tokio::test]
280 async fn admin_removal_hides_the_item_and_blocks_creator_republish() {
281 let mut h = TestHarness::new().await;
282 let (owner, project) = creator_project(&mut h, "adm").await;
283 let item = make_item(&h, project, "Flagged Item").await;
284 assert!(is_public(&h, item).await, "item starts public");
285
286 let removed = items::admin_remove_item(&h.db, item, "violates policy")
287 .await
288 .expect("admin_remove_item");
289 assert!(
290 removed.removed_by_admin,
291 "the item is flagged removed_by_admin"
292 );
293 assert!(!removed.is_public, "admin removal unpublishes the item");
294 assert_eq!(removed.removal_reason.as_deref(), Some("violates policy"));
295
296 // The creator cannot republish an admin-removed item: update_item's CASE
297 // forces is_public back to false while removed_by_admin is set.
298 let attempt = items::update_item(
299 &h.db,
300 item,
301 owner,
302 None,
303 None,
304 None,
305 None,
306 Some(true),
307 None,
308 None,
309 None,
310 None,
311 None,
312 None,
313 )
314 .await
315 .expect("owner update runs (row is theirs)");
316 assert!(
317 !attempt.is_public,
318 "a removed item cannot be republished by its creator"
319 );
320
321 // Admin restore clears the flags (but leaves it unpublished for manual republish).
322 let restored = items::admin_restore_item(&h.db, item)
323 .await
324 .expect("admin_restore_item");
325 assert!(
326 !restored.removed_by_admin,
327 "restore clears the admin-removed flag"
328 );
329 assert!(restored.removal_reason.is_none());
330 }
331