Skip to main content

max / makenotwork

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