Skip to main content

max / makenotwork

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