Skip to main content

max / makenotwork

11.6 KB · 396 lines History Blame Raw
1 //! Chapters: CRUD lifecycle, ordering, ownership, validation, draft visibility.
2
3 use crate::harness::TestHarness;
4 use serde_json::Value;
5
6 /// Helper: create a creator with a project and an audio item, return (project_id, item_id).
7 async fn setup_creator_with_audio_item(
8 h: &mut TestHarness,
9 username: &str,
10 _email: &str,
11 ) -> (String, String) {
12 let setup = h.create_creator_with_item(username, "audio", 0).await;
13 (setup.project_id, setup.item_id)
14 }
15
16 #[tokio::test]
17 async fn chapter_create_update_delete() {
18 let mut h = TestHarness::new().await;
19 let (_project_id, item_id) =
20 setup_creator_with_audio_item(&mut h, "chcreator", "chcreator@test.com").await;
21
22 // Create chapter
23 let resp = h
24 .client
25 .post_json(
26 &format!("/api/items/{item_id}/chapters"),
27 r#"{"title": "Intro", "start_seconds": 0.0, "sort_order": 1}"#,
28 )
29 .await;
30 assert!(
31 resp.status.is_success(),
32 "Create chapter failed: {} {}",
33 resp.status,
34 resp.text
35 );
36 let chapter: Value = resp.json();
37 let chapter_id = chapter["id"].as_str().unwrap().to_string();
38 assert_eq!(chapter["title"].as_str().unwrap(), "Intro");
39
40 // Update chapter
41 let resp = h
42 .client
43 .put_json(
44 &format!("/api/chapters/{chapter_id}"),
45 r#"{"title": "Introduction", "start_seconds": 5.0, "sort_order": 1}"#,
46 )
47 .await;
48 assert!(
49 resp.status.is_success(),
50 "Update chapter failed: {} {}",
51 resp.status,
52 resp.text
53 );
54 let updated: Value = resp.json();
55 assert_eq!(updated["title"].as_str().unwrap(), "Introduction");
56
57 // Delete chapter
58 let resp = h
59 .client
60 .delete(&format!("/api/chapters/{chapter_id}"))
61 .await;
62 assert!(
63 resp.status.is_success(),
64 "Delete chapter failed: {} {}",
65 resp.status,
66 resp.text
67 );
68
69 // Verify gone from DB
70 let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM chapters WHERE id = $1")
71 .bind(chapter_id.parse::<uuid::Uuid>().unwrap())
72 .fetch_one(&h.db)
73 .await
74 .unwrap();
75 assert_eq!(count, 0, "Chapter should be deleted from database");
76 }
77
78 #[tokio::test]
79 async fn chapter_ordering() {
80 let mut h = TestHarness::new().await;
81 let (project_id, item_id) =
82 setup_creator_with_audio_item(&mut h, "chorder", "chorder@test.com").await;
83
84 // Create 3 chapters out of order
85 for (title, sort, secs) in [
86 ("Third", 3, 60.0f32),
87 ("First", 1, 0.0),
88 ("Second", 2, 30.0),
89 ] {
90 let body =
91 format!(r#"{{"title": "{title}", "start_seconds": {secs}, "sort_order": {sort}}}"#);
92 let resp = h
93 .client
94 .post_json(&format!("/api/items/{item_id}/chapters"), &body)
95 .await;
96 assert!(
97 resp.status.is_success(),
98 "Create chapter '{title}' failed: {} {}",
99 resp.status,
100 resp.text
101 );
102 }
103
104 // Make item public so list endpoint works
105 h.client
106 .put_form(&format!("/api/items/{item_id}"), "is_public=true")
107 .await;
108 h.client
109 .put_json(
110 &format!("/api/projects/{project_id}"),
111 r#"{"is_public": true}"#,
112 )
113 .await;
114
115 // List chapters, should be sorted by sort_order
116 let resp = h
117 .client
118 .get(&format!("/api/items/{item_id}/chapters"))
119 .await;
120 assert!(
121 resp.status.is_success(),
122 "List chapters failed: {} {}",
123 resp.status,
124 resp.text
125 );
126 let list: Value = resp.json();
127 let data = list["data"].as_array().unwrap();
128 assert_eq!(data.len(), 3);
129 assert_eq!(data[0]["title"].as_str().unwrap(), "First");
130 assert_eq!(data[1]["title"].as_str().unwrap(), "Second");
131 assert_eq!(data[2]["title"].as_str().unwrap(), "Third");
132 }
133
134 #[tokio::test]
135 async fn chapter_ownership_enforced() {
136 let mut h = TestHarness::new().await;
137 let (_project_id, item_id) =
138 setup_creator_with_audio_item(&mut h, "chowner", "chowner@test.com").await;
139
140 // Creator A creates a chapter
141 let resp = h
142 .client
143 .post_json(
144 &format!("/api/items/{item_id}/chapters"),
145 r#"{"title": "Owner Chapter", "start_seconds": 0.0, "sort_order": 1}"#,
146 )
147 .await;
148 assert!(resp.status.is_success());
149 let chapter: Value = resp.json();
150 let chapter_id = chapter["id"].as_str().unwrap().to_string();
151
152 // Switch to creator B
153 h.client.post_form("/logout", "").await;
154 let b_id = h
155 .signup("chintruder", "chintruder@test.com", "password123")
156 .await;
157 h.grant_creator(b_id).await;
158 h.client.post_form("/logout", "").await;
159 h.login("chintruder", "password123").await;
160
161 // Creator B tries to update A's chapter
162 let resp = h
163 .client
164 .put_json(
165 &format!("/api/chapters/{chapter_id}"),
166 r#"{"title": "Hacked", "start_seconds": 0.0, "sort_order": 1}"#,
167 )
168 .await;
169 assert_eq!(
170 resp.status, 403,
171 "Non-owner should get 403 on PUT, got {} {}",
172 resp.status, resp.text
173 );
174
175 // Creator B tries to delete A's chapter
176 let resp = h
177 .client
178 .delete(&format!("/api/chapters/{chapter_id}"))
179 .await;
180 assert_eq!(
181 resp.status, 403,
182 "Non-owner should get 403 on DELETE, got {} {}",
183 resp.status, resp.text
184 );
185 }
186
187 #[tokio::test]
188 async fn chapter_title_validation() {
189 let mut h = TestHarness::new().await;
190 let (_project_id, item_id) =
191 setup_creator_with_audio_item(&mut h, "chvalid", "chvalid@test.com").await;
192
193 // Empty title
194 let resp = h
195 .client
196 .post_json(
197 &format!("/api/items/{item_id}/chapters"),
198 r#"{"title": "", "start_seconds": 0.0, "sort_order": 1}"#,
199 )
200 .await;
201 assert!(
202 resp.status == 400 || resp.status == 422,
203 "Empty title should be rejected, got {} {}",
204 resp.status,
205 resp.text
206 );
207
208 // 201-char title (exceeds 200 limit)
209 let long_title = "A".repeat(201);
210 let body = format!(r#"{{"title": "{long_title}", "start_seconds": 0.0, "sort_order": 1}}"#);
211 let resp = h
212 .client
213 .post_json(&format!("/api/items/{item_id}/chapters"), &body)
214 .await;
215 assert!(
216 resp.status == 400 || resp.status == 422,
217 "201-char title should be rejected, got {} {}",
218 resp.status,
219 resp.text
220 );
221 }
222
223 #[tokio::test]
224 async fn list_chapters_requires_public_item() {
225 let mut h = TestHarness::new().await;
226 let (_project_id, item_id) =
227 setup_creator_with_audio_item(&mut h, "chdraft", "chdraft@test.com").await;
228
229 // Add a chapter, then make item non-public
230 let resp = h
231 .client
232 .post_json(
233 &format!("/api/items/{item_id}/chapters"),
234 r#"{"title": "Draft Chapter", "start_seconds": 0.0, "sort_order": 1}"#,
235 )
236 .await;
237 assert!(resp.status.is_success());
238
239 // Mark item as draft (not public)
240 h.client
241 .put_form(&format!("/api/items/{item_id}"), "is_public=false")
242 .await;
243
244 // Logout, unauthenticated user tries to list chapters of draft item
245 h.client.post_form("/logout", "").await;
246 h.client.fetch_csrf_token().await;
247
248 let resp = h
249 .client
250 .get(&format!("/api/items/{item_id}/chapters"))
251 .await;
252 assert_eq!(
253 resp.status, 404,
254 "Draft item chapters should return 404, got {} {}",
255 resp.status, resp.text
256 );
257 }
258
259 #[tokio::test]
260 async fn chapter_create_on_non_owned_item() {
261 let mut h = TestHarness::new().await;
262 let (_project_id, item_id) =
263 setup_creator_with_audio_item(&mut h, "chownpost", "chownpost@test.com").await;
264
265 // Switch to creator B
266 h.client.post_form("/logout", "").await;
267 let b_id = h
268 .signup("chownpostb", "chownpostb@test.com", "password123")
269 .await;
270 h.grant_creator(b_id).await;
271 h.client.post_form("/logout", "").await;
272 h.login("chownpostb", "password123").await;
273
274 // Creator B tries to POST a chapter on A's item
275 let resp = h
276 .client
277 .post_json(
278 &format!("/api/items/{item_id}/chapters"),
279 r#"{"title": "Hacked", "start_seconds": 0.0, "sort_order": 1}"#,
280 )
281 .await;
282 assert_eq!(
283 resp.status, 403,
284 "Non-owner should get 403 on POST chapter, got {} {}",
285 resp.status, resp.text
286 );
287 }
288
289 #[tokio::test]
290 async fn chapter_unauthenticated_rejected() {
291 let mut h = TestHarness::new().await;
292 let (_project_id, item_id) =
293 setup_creator_with_audio_item(&mut h, "chunauth", "chunauth@test.com").await;
294
295 // Create a chapter so we have an ID for PUT/DELETE
296 let resp = h
297 .client
298 .post_json(
299 &format!("/api/items/{item_id}/chapters"),
300 r#"{"title": "Temp", "start_seconds": 0.0, "sort_order": 1}"#,
301 )
302 .await;
303 assert!(resp.status.is_success());
304 let chapter: Value = resp.json();
305 let chapter_id = chapter["id"].as_str().unwrap().to_string();
306
307 h.client.post_form("/logout", "").await;
308 h.client.fetch_csrf_token().await;
309
310 // POST chapter, should be 401
311 let resp = h
312 .client
313 .post_json(
314 &format!("/api/items/{item_id}/chapters"),
315 r#"{"title": "No Auth", "start_seconds": 0.0, "sort_order": 1}"#,
316 )
317 .await;
318 assert_eq!(
319 resp.status, 401,
320 "Unauthenticated POST chapter should be 401, got {} {}",
321 resp.status, resp.text
322 );
323
324 // PUT chapter, should be 401
325 let resp = h
326 .client
327 .put_json(
328 &format!("/api/chapters/{chapter_id}"),
329 r#"{"title": "No Auth", "start_seconds": 0.0, "sort_order": 1}"#,
330 )
331 .await;
332 assert_eq!(
333 resp.status, 401,
334 "Unauthenticated PUT chapter should be 401, got {} {}",
335 resp.status, resp.text
336 );
337
338 // DELETE chapter, should be 401
339 let resp = h
340 .client
341 .delete(&format!("/api/chapters/{chapter_id}"))
342 .await;
343 assert_eq!(
344 resp.status, 401,
345 "Unauthenticated DELETE chapter should be 401, got {} {}",
346 resp.status, resp.text
347 );
348 }
349
350 #[tokio::test]
351 async fn chapter_title_boundary_succeeds() {
352 let mut h = TestHarness::new().await;
353 let (_project_id, item_id) =
354 setup_creator_with_audio_item(&mut h, "chbound", "chbound@test.com").await;
355
356 // Exactly 200 characters, should succeed
357 let title_200 = "A".repeat(200);
358 let body = format!(r#"{{"title": "{title_200}", "start_seconds": 0.0, "sort_order": 1}}"#);
359 let resp = h
360 .client
361 .post_json(&format!("/api/items/{item_id}/chapters"), &body)
362 .await;
363 assert!(
364 resp.status.is_success(),
365 "200-char title should be accepted, got {} {}",
366 resp.status,
367 resp.text
368 );
369 let chapter: Value = resp.json();
370 assert_eq!(chapter["title"].as_str().unwrap(), title_200);
371 }
372
373 #[tokio::test]
374 async fn chapter_create_nonexistent_item() {
375 let mut h = TestHarness::new().await;
376 // Need a creator user to pass auth
377 let user_id = h.signup("chghost", "chghost@test.com", "password123").await;
378 h.grant_creator(user_id).await;
379 h.client.post_form("/logout", "").await;
380 h.login("chghost", "password123").await;
381
382 let fake_id = uuid::Uuid::new_v4();
383 let resp = h
384 .client
385 .post_json(
386 &format!("/api/items/{fake_id}/chapters"),
387 r#"{"title": "Ghost", "start_seconds": 0.0, "sort_order": 1}"#,
388 )
389 .await;
390 assert_eq!(
391 resp.status, 404,
392 "Chapter on nonexistent item should be 404, got {} {}",
393 resp.status, resp.text
394 );
395 }
396