Skip to main content

max / makenotwork

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