Skip to main content

max / makenotwork

13.4 KB · 390 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.signup(username, &format!("{}@test.com", username), "password123").await;
9 h.grant_creator(user_id).await;
10 h.client.post_form("/logout", "").await;
11 h.login(username, "password123").await;
12
13 let resp = h
14 .client
15 .post_form("/api/projects", &format!("slug={}-proj&title=Project", username))
16 .await;
17 assert!(resp.status.is_success(), "Create project failed: {}", resp.text);
18 let project: Value = resp.json();
19 let project_id = project["id"].as_str().unwrap().to_string();
20
21 let mut item_ids = Vec::new();
22 for i in 0..n {
23 let resp = h
24 .client
25 .post_form(
26 &format!("/api/projects/{}/items", project_id),
27 &format!("title=Item+{}&price_cents=500&item_type=digital", i + 1),
28 )
29 .await;
30 assert!(resp.status.is_success(), "Create item {} failed: {}", i + 1, resp.text);
31 let item: Value = resp.json();
32 item_ids.push(item["id"].as_str().unwrap().to_string());
33 }
34
35 (project_id, item_ids)
36 }
37
38 // ---------------------------------------------------------------------------
39 // Duplication
40 // ---------------------------------------------------------------------------
41
42 #[tokio::test]
43 async fn duplicate_item_creates_draft_copy() {
44 let mut h = TestHarness::new().await;
45 let (_, item_ids) = setup_with_items(&mut h, "dupuser", 1).await;
46 let item_id = &item_ids[0];
47
48 // Add a tag to the original
49 let resp = h.client.get("/api/tags/search?q=music").await;
50 let tags: Vec<Value> = serde_json::from_str(&resp.text).unwrap();
51 if !tags.is_empty() {
52 let tag_id = tags[0]["id"].as_str().unwrap();
53 h.client
54 .post_form(
55 &format!("/api/items/{}/tags", item_id),
56 &format!("tag_id={}", tag_id),
57 )
58 .await;
59 }
60
61 // Duplicate
62 let resp = h
63 .client
64 .post_form(&format!("/api/items/{}/duplicate", item_id), "")
65 .await;
66 assert!(resp.status.is_success(), "Duplicate failed: {} {}", resp.status, resp.text);
67 let dup: Value = resp.json();
68 let dup_title = dup["title"].as_str().unwrap();
69 assert!(
70 dup_title.starts_with("Copy of"),
71 "Duplicated title should start with 'Copy of': {}",
72 dup_title
73 );
74 assert_eq!(dup["is_public"].as_bool(), Some(false), "Duplicate should be draft");
75 assert_ne!(dup["id"].as_str(), Some(item_id.as_str()), "Should have new ID");
76 }
77
78 #[tokio::test]
79 async fn duplicate_preserves_price() {
80 let mut h = TestHarness::new().await;
81 let (_, item_ids) = setup_with_items(&mut h, "dupprice", 1).await;
82
83 let resp = h
84 .client
85 .post_form(&format!("/api/items/{}/duplicate", item_ids[0]), "")
86 .await;
87 assert!(resp.status.is_success(), "Duplicate failed: {}", resp.text);
88 let dup: Value = resp.json();
89 assert_eq!(dup["price_cents"].as_i64(), Some(500), "Price should be preserved");
90 }
91
92 #[tokio::test]
93 async fn duplicate_non_owner_rejected() {
94 let mut h = TestHarness::new().await;
95 let (_, item_ids) = setup_with_items(&mut h, "dupowner", 1).await;
96
97 // Switch to different user
98 h.client.post_form("/logout", "").await;
99 let other_id = h.signup("dupother", "dupother@test.com", "password123").await;
100 h.grant_creator(other_id).await;
101 h.client.post_form("/logout", "").await;
102 h.login("dupother", "password123").await;
103
104 let resp = h
105 .client
106 .post_form(&format!("/api/items/{}/duplicate", item_ids[0]), "")
107 .await;
108 assert_eq!(resp.status, 403, "Non-owner duplicate should be 403: {}", resp.text);
109 }
110
111 // ---------------------------------------------------------------------------
112 // Bulk operations
113 // ---------------------------------------------------------------------------
114
115 #[tokio::test]
116 async fn bulk_publish_items() {
117 let mut h = TestHarness::new().await;
118 let (_, item_ids) = setup_with_items(&mut h, "bulkpub", 3).await;
119
120 let body = item_ids
121 .iter()
122 .map(|id| format!("item_ids={}", id))
123 .collect::<Vec<_>>()
124 .join("&");
125 let resp = h.client.post_form("/api/items/bulk/publish", &body).await;
126 assert!(resp.status.is_success(), "Bulk publish failed: {} {}", resp.status, resp.text);
127
128 // Verify all items are now public
129 for item_id in &item_ids {
130 let is_public: bool =
131 sqlx::query_scalar("SELECT is_public FROM items WHERE id = $1::uuid")
132 .bind(item_id)
133 .fetch_one(&h.db)
134 .await
135 .unwrap();
136 assert!(is_public, "Item {} should be public after bulk publish", item_id);
137 }
138 }
139
140 #[tokio::test]
141 async fn bulk_unpublish_items() {
142 let mut h = TestHarness::new().await;
143 let (_, item_ids) = setup_with_items(&mut h, "bulkunpub", 2).await;
144
145 // First publish them
146 let body = item_ids
147 .iter()
148 .map(|id| format!("item_ids={}", id))
149 .collect::<Vec<_>>()
150 .join("&");
151 h.client.post_form("/api/items/bulk/publish", &body).await;
152
153 // Then unpublish
154 let resp = h.client.post_form("/api/items/bulk/unpublish", &body).await;
155 assert!(resp.status.is_success(), "Bulk unpublish failed: {} {}", resp.status, resp.text);
156
157 for item_id in &item_ids {
158 let is_public: bool =
159 sqlx::query_scalar("SELECT is_public FROM items WHERE id = $1::uuid")
160 .bind(item_id)
161 .fetch_one(&h.db)
162 .await
163 .unwrap();
164 assert!(!is_public, "Item {} should be draft after bulk unpublish", item_id);
165 }
166 }
167
168 #[tokio::test]
169 async fn bulk_delete_items() {
170 let mut h = TestHarness::new().await;
171 let (_, item_ids) = setup_with_items(&mut h, "bulkdel", 3).await;
172
173 let body = item_ids
174 .iter()
175 .map(|id| format!("item_ids={}", id))
176 .collect::<Vec<_>>()
177 .join("&");
178 let resp = h.client.post_form("/api/items/bulk/delete", &body).await;
179 assert!(resp.status.is_success(), "Bulk delete failed: {} {}", resp.status, resp.text);
180
181 let count: i64 = sqlx::query_scalar(
182 "SELECT COUNT(*) FROM items WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL",
183 )
184 .bind(
185 item_ids
186 .iter()
187 .map(|s| s.parse::<uuid::Uuid>().unwrap())
188 .collect::<Vec<_>>(),
189 )
190 .fetch_one(&h.db)
191 .await
192 .unwrap();
193 assert_eq!(count, 0, "All items should be soft-deleted");
194 }
195
196 #[tokio::test]
197 async fn bulk_empty_selection_rejected() {
198 let mut h = TestHarness::new().await;
199 let _ = setup_with_items(&mut h, "bulkempty", 0).await;
200
201 let resp = h.client.post_form("/api/items/bulk/publish", "").await;
202 assert!(
203 resp.status.is_client_error(),
204 "Empty bulk publish should fail: {} {}",
205 resp.status, resp.text
206 );
207 }
208
209 #[tokio::test]
210 async fn bulk_cross_user_rejected() {
211 let mut h = TestHarness::new().await;
212 let (_, item_ids) = setup_with_items(&mut h, "bulkauth", 1).await;
213
214 // Switch to different user
215 h.client.post_form("/logout", "").await;
216 let other = h.signup("bulkother", "bulkother@test.com", "password123").await;
217 h.grant_creator(other).await;
218 h.client.post_form("/logout", "").await;
219 h.login("bulkother", "password123").await;
220
221 let body = format!("item_ids={}", item_ids[0]);
222 let resp = h.client.post_form("/api/items/bulk/publish", &body).await;
223 assert_eq!(resp.status, 403, "Cross-user bulk should be 403: {}", resp.text);
224 }
225
226 // ---------------------------------------------------------------------------
227 // PWYW
228 // ---------------------------------------------------------------------------
229
230 #[tokio::test]
231 async fn pwyw_enable_and_set_minimum() {
232 let mut h = TestHarness::new().await;
233 let (_, item_ids) = setup_with_items(&mut h, "pwywenable", 1).await;
234 let item_id = &item_ids[0];
235
236 // Enable PWYW with minimum $5
237 let resp = h
238 .client
239 .put_form(
240 &format!("/api/items/{}", item_id),
241 "pwyw_enabled=on&pwyw_min_cents=500",
242 )
243 .await;
244 assert!(resp.status.is_success(), "Enable PWYW failed: {} {}", resp.status, resp.text);
245
246 // Verify in DB
247 let (enabled, min): (bool, i32) = sqlx::query_as(
248 "SELECT pwyw_enabled, pwyw_min_cents FROM items WHERE id = $1::uuid",
249 )
250 .bind(item_id)
251 .fetch_one(&h.db)
252 .await
253 .unwrap();
254 assert!(enabled, "PWYW should be enabled");
255 assert_eq!(min, 500, "PWYW min should be $5");
256 }
257
258 #[tokio::test]
259 async fn pwyw_disable() {
260 let mut h = TestHarness::new().await;
261 let (_, item_ids) = setup_with_items(&mut h, "pwywnope", 1).await;
262 let item_id = &item_ids[0];
263
264 // Enable then disable
265 h.client
266 .put_form(&format!("/api/items/{}", item_id), "pwyw_enabled=on&pwyw_min_cents=100")
267 .await;
268 let resp = h
269 .client
270 .put_form(&format!("/api/items/{}", item_id), "pwyw_enabled=off")
271 .await;
272 assert!(resp.status.is_success(), "Disable PWYW failed: {} {}", resp.status, resp.text);
273
274 let enabled: bool =
275 sqlx::query_scalar("SELECT pwyw_enabled FROM items WHERE id = $1::uuid")
276 .bind(item_id)
277 .fetch_one(&h.db)
278 .await
279 .unwrap();
280 assert!(!enabled, "PWYW should be disabled");
281 }
282
283 // ---------------------------------------------------------------------------
284 // Scheduled publishing
285 // ---------------------------------------------------------------------------
286
287 #[tokio::test]
288 async fn scheduled_publish_keeps_item_draft() {
289 let mut h = TestHarness::new().await;
290 let (_, item_ids) = setup_with_items(&mut h, "sched", 1).await;
291 let item_id = &item_ids[0];
292
293 // Set publish_at to future date — item should stay draft
294 let resp = h
295 .client
296 .put_form(
297 &format!("/api/items/{}", item_id),
298 "publish_at=2030-01-01T00:00:00Z&is_public=true",
299 )
300 .await;
301 assert!(resp.status.is_success(), "Schedule publish failed: {} {}", resp.status, resp.text);
302
303 let is_public: bool =
304 sqlx::query_scalar("SELECT is_public FROM items WHERE id = $1::uuid")
305 .bind(item_id)
306 .fetch_one(&h.db)
307 .await
308 .unwrap();
309 assert!(!is_public, "Item should remain draft when scheduled for future");
310
311 let publish_at: Option<chrono::DateTime<chrono::Utc>> =
312 sqlx::query_scalar("SELECT publish_at FROM items WHERE id = $1::uuid")
313 .bind(item_id)
314 .fetch_one(&h.db)
315 .await
316 .unwrap();
317 assert!(publish_at.is_some(), "publish_at should be set");
318 }
319
320 #[tokio::test]
321 async fn clear_scheduled_publish() {
322 let mut h = TestHarness::new().await;
323 let (_, item_ids) = setup_with_items(&mut h, "unsched", 1).await;
324 let item_id = &item_ids[0];
325
326 // Schedule then clear
327 h.client
328 .put_form(
329 &format!("/api/items/{}", item_id),
330 "publish_at=2030-01-01T00:00:00Z",
331 )
332 .await;
333 let resp = h
334 .client
335 .put_form(&format!("/api/items/{}", item_id), "publish_at=")
336 .await;
337 assert!(resp.status.is_success(), "Clear schedule failed: {} {}", resp.status, resp.text);
338
339 let publish_at: Option<chrono::DateTime<chrono::Utc>> =
340 sqlx::query_scalar("SELECT publish_at FROM items WHERE id = $1::uuid")
341 .bind(item_id)
342 .fetch_one(&h.db)
343 .await
344 .unwrap();
345 assert!(publish_at.is_none(), "publish_at should be cleared");
346 }
347
348 // ---------------------------------------------------------------------------
349 // Text content
350 // ---------------------------------------------------------------------------
351
352 #[tokio::test]
353 async fn text_content_word_count() {
354 let mut h = TestHarness::new().await;
355 let user_id = h.signup("textcount", "textcount@test.com", "password123").await;
356 h.grant_creator(user_id).await;
357 h.client.post_form("/logout", "").await;
358 h.login("textcount", "password123").await;
359
360 let resp = h
361 .client
362 .post_form("/api/projects", "slug=text-proj&title=Text+Project")
363 .await;
364 let project: Value = resp.json();
365 let project_id = project["id"].as_str().unwrap();
366
367 let resp = h
368 .client
369 .post_form(
370 &format!("/api/projects/{}/items", project_id),
371 "title=My+Essay&item_type=text",
372 )
373 .await;
374 let item: Value = resp.json();
375 let item_id = item["id"].as_str().unwrap();
376
377 // Set text content with known word count
378 let body = "one two three four five six seven eight nine ten";
379 let resp = h
380 .client
381 .put_json(
382 &format!("/api/items/{}/text", item_id),
383 &format!(r#"{{"body": "{}"}}"#, body),
384 )
385 .await;
386 assert!(resp.status.is_success(), "Set text failed: {}", resp.text);
387 let data: Value = resp.json();
388 assert_eq!(data["word_count"].as_u64(), Some(10), "Should count 10 words");
389 }
390