Skip to main content

max / makenotwork

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