Skip to main content

max / makenotwork

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