Skip to main content

max / makenotwork

14.3 KB · 504 lines History Blame Raw
1 //! Item management workflow tests, duplication, bulk operations, PWYW, scheduling.
2
3 use crate::harness::TestHarness;
4 use serde_json::Value;
5
6 /// Helper: create a creator with a project and N items. Returns (project_id, item_ids).
7 async fn setup_with_items(h: &mut TestHarness, username: &str, n: usize) -> (String, Vec<String>) {
8 let user_id = h
9 .signup(username, &format!("{username}@test.com"), "password123")
10 .await;
11 h.grant_creator(user_id).await;
12 h.client.post_form("/logout", "").await;
13 h.login(username, "password123").await;
14
15 let resp = h
16 .client
17 .post_form(
18 "/api/projects",
19 &format!("slug={username}-proj&title=Project"),
20 )
21 .await;
22 assert_eq!(resp.status, 200, "Create project failed: {}", resp.text);
23 let project: Value = resp.json();
24 let project_id = project["id"].as_str().unwrap().to_string();
25
26 let mut item_ids = Vec::new();
27 for i in 0..n {
28 let resp = h
29 .client
30 .post_form(
31 &format!("/api/projects/{project_id}/items"),
32 &format!("title=Item+{}&price_cents=500&item_type=digital", i + 1),
33 )
34 .await;
35 assert_eq!(
36 resp.status,
37 200,
38 "Create item {} failed: {}",
39 i + 1,
40 resp.text
41 );
42 let item: Value = resp.json();
43 item_ids.push(item["id"].as_str().unwrap().to_string());
44 }
45
46 (project_id, item_ids)
47 }
48
49 // Duplication
50
51 #[tokio::test]
52 async fn duplicate_item_creates_draft_copy() {
53 let mut h = TestHarness::new().await;
54 let (_, item_ids) = setup_with_items(&mut h, "dupuser", 1).await;
55 let item_id = &item_ids[0];
56
57 // Add a tag to the original
58 let resp = h.client.get("/api/tags/search?q=music").await;
59 let tags: Vec<Value> = serde_json::from_str(&resp.text).unwrap();
60 if !tags.is_empty() {
61 let tag_id = tags[0]["id"].as_str().unwrap();
62 h.client
63 .post_form(
64 &format!("/api/items/{item_id}/tags"),
65 &format!("tag_id={tag_id}"),
66 )
67 .await;
68 }
69
70 // Duplicate
71 let resp = h
72 .client
73 .post_form(&format!("/api/items/{item_id}/duplicate"), "")
74 .await;
75 assert_eq!(
76 resp.status, 200,
77 "Duplicate failed: {} {}",
78 resp.status, resp.text
79 );
80 let dup: Value = resp.json();
81 let dup_title = dup["title"].as_str().unwrap();
82 assert!(
83 dup_title.starts_with("Copy of"),
84 "Duplicated title should start with 'Copy of': {dup_title}"
85 );
86 assert_eq!(
87 dup["is_public"].as_bool(),
88 Some(false),
89 "Duplicate should be draft"
90 );
91 assert_ne!(
92 dup["id"].as_str(),
93 Some(item_id.as_str()),
94 "Should have new ID"
95 );
96 }
97
98 #[tokio::test]
99 async fn duplicate_preserves_price() {
100 let mut h = TestHarness::new().await;
101 let (_, item_ids) = setup_with_items(&mut h, "dupprice", 1).await;
102
103 let resp = h
104 .client
105 .post_form(&format!("/api/items/{}/duplicate", item_ids[0]), "")
106 .await;
107 assert_eq!(resp.status, 200, "Duplicate failed: {}", resp.text);
108 let dup: Value = resp.json();
109 assert_eq!(
110 dup["price_cents"].as_i64(),
111 Some(500),
112 "Price should be preserved"
113 );
114 }
115
116 #[tokio::test]
117 async fn duplicate_non_owner_rejected() {
118 let mut h = TestHarness::new().await;
119 let (_, item_ids) = setup_with_items(&mut h, "dupowner", 1).await;
120
121 // Switch to different user
122 h.client.post_form("/logout", "").await;
123 let other_id = h
124 .signup("dupother", "dupother@test.com", "password123")
125 .await;
126 h.grant_creator(other_id).await;
127 h.client.post_form("/logout", "").await;
128 h.login("dupother", "password123").await;
129
130 let resp = h
131 .client
132 .post_form(&format!("/api/items/{}/duplicate", item_ids[0]), "")
133 .await;
134 assert_eq!(
135 resp.status, 403,
136 "Non-owner duplicate should be 403: {}",
137 resp.text
138 );
139 }
140
141 // Bulk operations
142
143 #[tokio::test]
144 async fn bulk_publish_items() {
145 let mut h = TestHarness::new().await;
146 let (_, item_ids) = setup_with_items(&mut h, "bulkpub", 3).await;
147
148 let body = item_ids
149 .iter()
150 .map(|id| format!("item_ids={id}"))
151 .collect::<Vec<_>>()
152 .join("&");
153 let resp = h.client.post_form("/api/items/bulk/publish", &body).await;
154 assert_eq!(
155 resp.status, 200,
156 "Bulk publish failed: {} {}",
157 resp.status, resp.text
158 );
159
160 // Verify all items are now public
161 for item_id in &item_ids {
162 let is_public: bool = sqlx::query_scalar("SELECT is_public FROM items WHERE id = $1::uuid")
163 .bind(item_id)
164 .fetch_one(&h.db)
165 .await
166 .unwrap();
167 assert!(
168 is_public,
169 "Item {item_id} should be public after bulk publish"
170 );
171 }
172 }
173
174 #[tokio::test]
175 async fn bulk_unpublish_items() {
176 let mut h = TestHarness::new().await;
177 let (_, item_ids) = setup_with_items(&mut h, "bulkunpub", 2).await;
178
179 // First publish them
180 let body = item_ids
181 .iter()
182 .map(|id| format!("item_ids={id}"))
183 .collect::<Vec<_>>()
184 .join("&");
185 h.client.post_form("/api/items/bulk/publish", &body).await;
186
187 let resp = h.client.post_form("/api/items/bulk/unpublish", &body).await;
188 assert_eq!(
189 resp.status, 200,
190 "Bulk unpublish failed: {} {}",
191 resp.status, resp.text
192 );
193
194 for item_id in &item_ids {
195 let is_public: bool = sqlx::query_scalar("SELECT is_public FROM items WHERE id = $1::uuid")
196 .bind(item_id)
197 .fetch_one(&h.db)
198 .await
199 .unwrap();
200 assert!(
201 !is_public,
202 "Item {item_id} should be draft after bulk unpublish"
203 );
204 }
205 }
206
207 #[tokio::test]
208 async fn bulk_delete_items() {
209 let mut h = TestHarness::new().await;
210 let (_, item_ids) = setup_with_items(&mut h, "bulkdel", 3).await;
211
212 let body = item_ids
213 .iter()
214 .map(|id| format!("item_ids={id}"))
215 .collect::<Vec<_>>()
216 .join("&");
217 let resp = h.client.post_form("/api/items/bulk/delete", &body).await;
218 assert_eq!(
219 resp.status, 200,
220 "Bulk delete failed: {} {}",
221 resp.status, resp.text
222 );
223
224 let count: i64 = sqlx::query_scalar(
225 "SELECT COUNT(*) FROM items WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL",
226 )
227 .bind(
228 item_ids
229 .iter()
230 .map(|s| s.parse::<uuid::Uuid>().unwrap())
231 .collect::<Vec<_>>(),
232 )
233 .fetch_one(&h.db)
234 .await
235 .unwrap();
236 assert_eq!(count, 0, "All items should be soft-deleted");
237 }
238
239 #[tokio::test]
240 async fn bulk_empty_selection_rejected() {
241 let mut h = TestHarness::new().await;
242 let _ = setup_with_items(&mut h, "bulkempty", 0).await;
243
244 let resp = h.client.post_form("/api/items/bulk/publish", "").await;
245 assert_eq!(
246 resp.status, 400,
247 "Empty bulk publish should fail: {} {}",
248 resp.status, resp.text
249 );
250 }
251
252 #[tokio::test]
253 async fn bulk_cross_user_rejected() {
254 let mut h = TestHarness::new().await;
255 let (_, item_ids) = setup_with_items(&mut h, "bulkauth", 1).await;
256
257 // Switch to different user
258 h.client.post_form("/logout", "").await;
259 let other = h
260 .signup("bulkother", "bulkother@test.com", "password123")
261 .await;
262 h.grant_creator(other).await;
263 h.client.post_form("/logout", "").await;
264 h.login("bulkother", "password123").await;
265
266 let body = format!("item_ids={}", item_ids[0]);
267 let resp = h.client.post_form("/api/items/bulk/publish", &body).await;
268 assert_eq!(
269 resp.status, 403,
270 "Cross-user bulk should be 403: {}",
271 resp.text
272 );
273 }
274
275 // PWYW
276
277 #[tokio::test]
278 async fn pwyw_enable_and_set_minimum() {
279 let mut h = TestHarness::new().await;
280 let (_, item_ids) = setup_with_items(&mut h, "pwywenable", 1).await;
281 let item_id = &item_ids[0];
282
283 // Enable PWYW with minimum $5
284 let resp = h
285 .client
286 .put_form(
287 &format!("/api/items/{item_id}"),
288 "pwyw_enabled=on&pwyw_min_cents=500",
289 )
290 .await;
291 assert_eq!(
292 resp.status, 200,
293 "Enable PWYW failed: {} {}",
294 resp.status, resp.text
295 );
296
297 // Verify in DB
298 let (enabled, min): (bool, i32) =
299 sqlx::query_as("SELECT pwyw_enabled, pwyw_min_cents FROM items WHERE id = $1::uuid")
300 .bind(item_id)
301 .fetch_one(&h.db)
302 .await
303 .unwrap();
304 assert!(enabled, "PWYW should be enabled");
305 assert_eq!(min, 500, "PWYW min should be $5");
306 }
307
308 #[tokio::test]
309 async fn pwyw_disable() {
310 let mut h = TestHarness::new().await;
311 let (_, item_ids) = setup_with_items(&mut h, "pwywnope", 1).await;
312 let item_id = &item_ids[0];
313
314 // Enable then disable
315 h.client
316 .put_form(
317 &format!("/api/items/{item_id}"),
318 "pwyw_enabled=on&pwyw_min_cents=100",
319 )
320 .await;
321 let resp = h
322 .client
323 .put_form(&format!("/api/items/{item_id}"), "pwyw_enabled=off")
324 .await;
325 assert_eq!(
326 resp.status, 200,
327 "Disable PWYW failed: {} {}",
328 resp.status, resp.text
329 );
330
331 let enabled: bool = sqlx::query_scalar("SELECT pwyw_enabled FROM items WHERE id = $1::uuid")
332 .bind(item_id)
333 .fetch_one(&h.db)
334 .await
335 .unwrap();
336 assert!(!enabled, "PWYW should be disabled");
337 }
338
339 // Scheduled publishing
340
341 #[tokio::test]
342 async fn scheduled_publish_keeps_item_draft() {
343 let mut h = TestHarness::new().await;
344 let (_, item_ids) = setup_with_items(&mut h, "sched", 1).await;
345 let item_id = &item_ids[0];
346
347 // Set publish_at to future date, item should stay draft
348 let resp = h
349 .client
350 .put_form(
351 &format!("/api/items/{item_id}"),
352 "publish_at=2030-01-01T00:00:00Z&is_public=true",
353 )
354 .await;
355 assert_eq!(
356 resp.status, 200,
357 "Schedule publish failed: {} {}",
358 resp.status, resp.text
359 );
360
361 let is_public: bool = sqlx::query_scalar("SELECT is_public FROM items WHERE id = $1::uuid")
362 .bind(item_id)
363 .fetch_one(&h.db)
364 .await
365 .unwrap();
366 assert!(
367 !is_public,
368 "Item should remain draft when scheduled for future"
369 );
370
371 let publish_at: Option<chrono::DateTime<chrono::Utc>> =
372 sqlx::query_scalar("SELECT publish_at FROM items WHERE id = $1::uuid")
373 .bind(item_id)
374 .fetch_one(&h.db)
375 .await
376 .unwrap();
377 assert!(publish_at.is_some(), "publish_at should be set");
378 }
379
380 #[tokio::test]
381 async fn clear_scheduled_publish() {
382 let mut h = TestHarness::new().await;
383 let (_, item_ids) = setup_with_items(&mut h, "unsched", 1).await;
384 let item_id = &item_ids[0];
385
386 // Schedule then clear
387 h.client
388 .put_form(
389 &format!("/api/items/{item_id}"),
390 "publish_at=2030-01-01T00:00:00Z",
391 )
392 .await;
393 let resp = h
394 .client
395 .put_form(&format!("/api/items/{item_id}"), "publish_at=")
396 .await;
397 assert_eq!(
398 resp.status, 200,
399 "Clear schedule failed: {} {}",
400 resp.status, resp.text
401 );
402
403 let publish_at: Option<chrono::DateTime<chrono::Utc>> =
404 sqlx::query_scalar("SELECT publish_at FROM items WHERE id = $1::uuid")
405 .bind(item_id)
406 .fetch_one(&h.db)
407 .await
408 .unwrap();
409 assert!(publish_at.is_none(), "publish_at should be cleared");
410 }
411
412 // Text content
413
414 #[tokio::test]
415 async fn text_content_word_count() {
416 let mut h = TestHarness::new().await;
417 let user_id = h
418 .signup("textcount", "textcount@test.com", "password123")
419 .await;
420 h.grant_creator(user_id).await;
421 h.client.post_form("/logout", "").await;
422 h.login("textcount", "password123").await;
423
424 let resp = h
425 .client
426 .post_form("/api/projects", "slug=text-proj&title=Text+Project")
427 .await;
428 let project: Value = resp.json();
429 let project_id = project["id"].as_str().unwrap();
430
431 let resp = h
432 .client
433 .post_form(
434 &format!("/api/projects/{project_id}/items"),
435 "title=My+Essay&item_type=text",
436 )
437 .await;
438 let item: Value = resp.json();
439 let item_id = item["id"].as_str().unwrap();
440
441 // Set text content with known word count
442 let body = "one two three four five six seven eight nine ten";
443 let resp = h
444 .client
445 .put_json(
446 &format!("/api/items/{item_id}/text"),
447 &format!(r#"{{"body": "{body}"}}"#),
448 )
449 .await;
450 assert_eq!(resp.status, 200, "Set text failed: {}", resp.text);
451 let data: Value = resp.json();
452 assert_eq!(
453 data["word_count"].as_u64(),
454 Some(10),
455 "Should count 10 words"
456 );
457 }
458
459 // create_item dedups slugs via the insert-retry on the per-project unique index
460 // (the redundant pre-insert SELECT was removed). Two items with the same title in
461 // one project must still get distinct slugs (base, base-2).
462 #[tokio::test]
463 async fn items_with_same_title_get_distinct_slugs() {
464 let mut h = TestHarness::new().await;
465 let user_id = h
466 .signup("slugdedup", "slugdedup@test.com", "password123")
467 .await;
468 h.grant_creator(user_id).await;
469 h.client.post_form("/logout", "").await;
470 h.login("slugdedup", "password123").await;
471
472 let resp = h
473 .client
474 .post_form("/api/projects", "slug=slugdedup-proj&title=Project")
475 .await;
476 assert_eq!(resp.status, 200, "create project: {}", resp.text);
477 let project: Value = resp.json();
478 let project_id = project["id"].as_str().unwrap().to_string();
479
480 for _ in 0..2 {
481 let resp = h
482 .client
483 .post_form(
484 &format!("/api/projects/{project_id}/items"),
485 "title=Same+Title&price_cents=0&item_type=digital",
486 )
487 .await;
488 assert_eq!(resp.status, 200, "create item: {}", resp.text);
489 }
490
491 let mut slugs: Vec<String> =
492 sqlx::query_scalar("SELECT slug FROM items WHERE project_id = $1::uuid")
493 .bind(&project_id)
494 .fetch_all(&h.db)
495 .await
496 .unwrap();
497 slugs.sort();
498 assert_eq!(
499 slugs,
500 vec!["same-title".to_string(), "same-title-2".to_string()],
501 "same-title items must get distinct slugs via the insert-retry dedup"
502 );
503 }
504