Skip to main content

max / makenotwork

18.0 KB · 575 lines History Blame Raw
1 //! Blog workflow: create project -> create post -> publish -> public page -> RSS
2
3 use crate::harness::TestHarness;
4 use serde_json::Value;
5
6 #[tokio::test]
7 async fn blog_post_lifecycle() {
8 let mut h = TestHarness::new().await;
9
10 // Setup: creator with project
11 let user_id = h
12 .signup("blogger", "blogger@example.com", "password123")
13 .await;
14 h.grant_creator(user_id).await;
15 h.client.post_form("/logout", "").await;
16 h.login("blogger", "password123").await;
17
18 let resp = h
19 .client
20 .post_form("/api/projects", "slug=my-blog&title=My+Blog")
21 .await;
22 let project: Value = resp.json();
23 let project_id = project["id"].as_str().unwrap();
24
25 // Make project public
26 h.client
27 .put_json(
28 &format!("/api/projects/{project_id}"),
29 r#"{"is_public": true}"#,
30 )
31 .await;
32
33 // Create blog post
34 let resp = h
35 .client
36 .post_json(
37 &format!("/api/projects/{project_id}/blog"),
38 r#"{"title": "First Post", "body_markdown": "Hello **world**!", "is_published": false}"#,
39 )
40 .await;
41 assert_eq!(
42 resp.status, 200,
43 "Create blog post failed: {} {}",
44 resp.status, resp.text
45 );
46 let post: Value = resp.json();
47 let post_id = post["id"].as_str().unwrap();
48 let post_slug = post["slug"].as_str().unwrap();
49
50 // Publish the post
51 let resp = h
52 .client
53 .put_json(
54 &format!("/api/blog/{post_id}"),
55 &format!(
56 r#"{{"title": "First Post", "slug": "{post_slug}", "body_markdown": "Hello **world**!", "is_published": true}}"#
57 ),
58 )
59 .await;
60 assert_eq!(
61 resp.status, 200,
62 "Publish blog post failed: {} {}",
63 resp.status, resp.text
64 );
65
66 // Check blog page is accessible
67 let blog_url = format!("/p/my-blog/blog/{post_slug}");
68 let resp = h.client.get(&blog_url).await;
69 assert_eq!(
70 resp.status, 200,
71 "Blog post page should be accessible at {blog_url}"
72 );
73 assert!(
74 resp.text.contains("First Post"),
75 "Blog post page should contain the title"
76 );
77
78 // Check RSS feed
79 let resp = h.client.get("/p/my-blog/blog/feed.xml").await;
80 assert_eq!(resp.status, 200, "RSS feed should be accessible");
81 assert!(
82 resp.text.contains("<rss") || resp.text.contains("<?xml"),
83 "RSS feed should be valid XML"
84 );
85 assert!(
86 resp.text.contains("First Post"),
87 "RSS feed should contain the blog post title"
88 );
89 }
90
91 #[tokio::test]
92 async fn blog_post_crud() {
93 let mut h = TestHarness::new().await;
94
95 // Setup: creator with project
96 let user_id = h
97 .signup("cruduser", "cruduser@example.com", "password123")
98 .await;
99 h.grant_creator(user_id).await;
100 h.client.post_form("/logout", "").await;
101 h.login("cruduser", "password123").await;
102
103 let resp = h
104 .client
105 .post_form("/api/projects", "slug=crud-blog&title=CRUD+Blog")
106 .await;
107 let project: Value = resp.json();
108 let project_id = project["id"].as_str().unwrap();
109
110 // Create a draft blog post
111 let resp = h
112 .client
113 .post_json(
114 &format!("/api/projects/{project_id}/blog"),
115 r#"{"title": "Draft Post", "body_markdown": "Initial body", "is_published": false}"#,
116 )
117 .await;
118 assert_eq!(
119 resp.status, 200,
120 "Create draft failed: {} {}",
121 resp.status, resp.text
122 );
123 let post: Value = resp.json();
124 let post_id = post["id"].as_str().unwrap();
125 let post_slug = post["slug"].as_str().unwrap();
126 assert_eq!(post["is_published"].as_bool(), Some(false));
127
128 // Read it back via GET /api/blog/{id}
129 let resp = h.client.get(&format!("/api/blog/{post_id}")).await;
130 assert_eq!(
131 resp.status, 200,
132 "Get blog post failed: {} {}",
133 resp.status, resp.text
134 );
135 let fetched: Value = resp.json();
136 assert_eq!(fetched["title"].as_str(), Some("Draft Post"));
137 assert_eq!(fetched["body_markdown"].as_str(), Some("Initial body"));
138 assert_eq!(fetched["is_published"].as_bool(), Some(false));
139
140 // Update title and body
141 let resp = h
142 .client
143 .put_json(
144 &format!("/api/blog/{post_id}"),
145 &format!(
146 r#"{{"title": "Updated Post", "slug": "{post_slug}", "body_markdown": "Updated body content", "is_published": false}}"#
147 ),
148 )
149 .await;
150 assert_eq!(
151 resp.status, 200,
152 "Update blog post failed: {} {}",
153 resp.status, resp.text
154 );
155
156 // Verify changes persisted
157 let resp = h.client.get(&format!("/api/blog/{post_id}")).await;
158 let fetched: Value = resp.json();
159 assert_eq!(fetched["title"].as_str(), Some("Updated Post"));
160 assert_eq!(
161 fetched["body_markdown"].as_str(),
162 Some("Updated body content")
163 );
164
165 // Publish the post
166 let resp = h
167 .client
168 .put_json(
169 &format!("/api/blog/{post_id}"),
170 &format!(
171 r#"{{"title": "Updated Post", "slug": "{post_slug}", "body_markdown": "Updated body content", "is_published": true}}"#
172 ),
173 )
174 .await;
175 assert_eq!(
176 resp.status, 200,
177 "Publish blog post failed: {} {}",
178 resp.status, resp.text
179 );
180
181 // Verify is_published
182 let resp = h.client.get(&format!("/api/blog/{post_id}")).await;
183 let fetched: Value = resp.json();
184 assert_eq!(fetched["is_published"].as_bool(), Some(true));
185
186 // Auto-save (omits is_published) must NOT unpublish a live post: a PUT
187 // without the field leaves publish state untouched.
188 let resp = h
189 .client
190 .put_json(
191 &format!("/api/blog/{post_id}"),
192 &format!(
193 r#"{{"title": "Updated Post", "slug": "{post_slug}", "body_markdown": "Body after autosave"}}"#
194 ),
195 )
196 .await;
197 assert_eq!(
198 resp.status, 200,
199 "Auto-save failed: {} {}",
200 resp.status, resp.text
201 );
202 let resp = h.client.get(&format!("/api/blog/{post_id}")).await;
203 let fetched: Value = resp.json();
204 assert_eq!(
205 fetched["is_published"].as_bool(),
206 Some(true),
207 "Auto-save (no is_published field) must not unpublish a live post"
208 );
209 assert_eq!(
210 fetched["body_markdown"].as_str(),
211 Some("Body after autosave")
212 );
213
214 // An explicit is_published:false still unpublishes (Save Draft on a live post).
215 let resp = h
216 .client
217 .put_json(
218 &format!("/api/blog/{post_id}"),
219 &format!(
220 r#"{{"title": "Updated Post", "slug": "{post_slug}", "body_markdown": "Body after autosave", "is_published": false}}"#
221 ),
222 )
223 .await;
224 assert_eq!(
225 resp.status, 200,
226 "Explicit unpublish failed: {} {}",
227 resp.status, resp.text
228 );
229 let resp = h.client.get(&format!("/api/blog/{post_id}")).await;
230 let fetched: Value = resp.json();
231 assert_eq!(
232 fetched["is_published"].as_bool(),
233 Some(false),
234 "Explicit is_published:false must unpublish"
235 );
236
237 let resp = h.client.delete(&format!("/api/blog/{post_id}")).await;
238 assert_eq!(
239 resp.status, 200,
240 "Delete blog post failed: {} {}",
241 resp.status, resp.text
242 );
243
244 // Verify 404 after deletion
245 let resp = h.client.get(&format!("/api/blog/{post_id}")).await;
246 assert_eq!(resp.status, 404, "Deleted blog post should return 404");
247 }
248
249 #[tokio::test]
250 async fn non_owner_cannot_edit_blog() {
251 let mut h = TestHarness::new().await;
252
253 // User A creates project + post
254 let user_a = h
255 .signup("blogowner", "blogowner@example.com", "password123")
256 .await;
257 h.grant_creator(user_a).await;
258 h.client.post_form("/logout", "").await;
259 h.login("blogowner", "password123").await;
260
261 let resp = h
262 .client
263 .post_form("/api/projects", "slug=owner-blog&title=Owner+Blog")
264 .await;
265 let project: Value = resp.json();
266 let project_id = project["id"].as_str().unwrap();
267
268 let resp = h
269 .client
270 .post_json(
271 &format!("/api/projects/{project_id}/blog"),
272 r#"{"title": "Owner Post", "body_markdown": "Secret content", "is_published": false}"#,
273 )
274 .await;
275 assert_eq!(resp.status, 200, "Create post failed: {}", resp.text);
276 let post: Value = resp.json();
277 let post_id = post["id"].as_str().unwrap();
278 let post_slug = post["slug"].as_str().unwrap();
279
280 // Log out user A, sign up user B
281 h.client.post_form("/logout", "").await;
282 let user_b = h
283 .signup("intruder", "intruder@example.com", "password123")
284 .await;
285 h.grant_creator(user_b).await;
286 h.client.post_form("/logout", "").await;
287 h.login("intruder", "password123").await;
288
289 // User B tries to update user A's post
290 let resp = h
291 .client
292 .put_json(
293 &format!("/api/blog/{post_id}"),
294 &format!(
295 r#"{{"title": "Hacked", "slug": "{post_slug}", "body_markdown": "Hacked body", "is_published": true}}"#
296 ),
297 )
298 .await;
299 assert_eq!(
300 resp.status, 403,
301 "Non-owner update should be 403, got {}",
302 resp.status
303 );
304
305 // User B tries to delete user A's post
306 let resp = h.client.delete(&format!("/api/blog/{post_id}")).await;
307 assert_eq!(
308 resp.status, 403,
309 "Non-owner delete should be 403, got {}",
310 resp.status
311 );
312 }
313
314 #[tokio::test]
315 async fn blog_post_list_respects_visibility() {
316 let mut h = TestHarness::new().await;
317
318 // Setup: creator with public project
319 let user_id = h
320 .signup("listuser", "listuser@example.com", "password123")
321 .await;
322 h.grant_creator(user_id).await;
323 h.client.post_form("/logout", "").await;
324 h.login("listuser", "password123").await;
325
326 let resp = h
327 .client
328 .post_form("/api/projects", "slug=list-blog&title=List+Blog")
329 .await;
330 let project: Value = resp.json();
331 let project_id = project["id"].as_str().unwrap();
332
333 // Make project public
334 h.client
335 .put_json(
336 &format!("/api/projects/{project_id}"),
337 r#"{"is_public": true}"#,
338 )
339 .await;
340
341 // Create a published post
342 let resp = h
343 .client
344 .post_json(
345 &format!("/api/projects/{project_id}/blog"),
346 r#"{"title": "Public Post", "body_markdown": "Visible to all", "is_published": true}"#,
347 )
348 .await;
349 assert_eq!(resp.status, 200, "Create public post failed: {}", resp.text);
350 let public_post: Value = resp.json();
351 let public_slug = public_post["slug"].as_str().unwrap();
352
353 // Create a draft post
354 let resp = h
355 .client
356 .post_json(
357 &format!("/api/projects/{project_id}/blog"),
358 r#"{"title": "Draft Post", "body_markdown": "Hidden from public", "is_published": false}"#,
359 )
360 .await;
361 assert_eq!(resp.status, 200, "Create draft post failed: {}", resp.text);
362
363 // List via API (public endpoint) should show only the published post
364 let resp = h
365 .client
366 .get(&format!("/api/projects/{project_id}/blog"))
367 .await;
368 assert_eq!(resp.status, 200, "List blog posts failed: {}", resp.text);
369 let list: Value = resp.json();
370 let posts = list["data"].as_array().unwrap();
371 assert_eq!(
372 posts.len(),
373 1,
374 "Public list should contain only 1 published post"
375 );
376 assert_eq!(posts[0]["title"].as_str(), Some("Public Post"));
377
378 // Verify the public post is accessible on the public page
379 let blog_url = format!("/p/list-blog/blog/{public_slug}");
380 let resp = h.client.get(&blog_url).await;
381 assert_eq!(
382 resp.status, 200,
383 "Published post should be accessible at {blog_url}"
384 );
385 assert!(
386 resp.text.contains("Public Post"),
387 "Page should contain the post title"
388 );
389 }
390
391 /// The landing "Last shipped" velocity line: suppressed with no eligible post,
392 /// surfaced for the most recent flagged changelog post, and never fed by
393 /// landing-flagged posts on non-changelog projects.
394 #[tokio::test]
395 async fn landing_velocity_line() {
396 let mut h = TestHarness::new().await;
397
398 // State 1: no eligible post on a fresh platform, line is suppressed.
399 let resp = h.client.get("/").await;
400 assert_eq!(resp.status, 200, "Landing should render: {}", resp.text);
401 assert!(
402 !resp.text.contains("Last shipped:"),
403 "Velocity line must be absent with zero eligible posts"
404 );
405
406 // Creator owns both a non-changelog project and the changelog project.
407 let user_id = h
408 .signup("shipper", "shipper@example.com", "password123")
409 .await;
410 h.grant_creator(user_id).await;
411 h.client.post_form("/logout", "").await;
412 h.login("shipper", "password123").await;
413
414 // A non-changelog project with a landing-flagged, published post.
415 let resp = h
416 .client
417 .post_form("/api/projects", "slug=updates&title=Updates")
418 .await;
419 let updates: Value = resp.json();
420 let updates_id = updates["id"].as_str().unwrap();
421 h.client
422 .put_json(
423 &format!("/api/projects/{updates_id}"),
424 r#"{"is_public": true}"#,
425 )
426 .await;
427 let resp = h
428 .client
429 .post_json(
430 &format!("/api/projects/{updates_id}/blog"),
431 r#"{"title": "Off-topic note", "body_markdown": "Body", "is_published": true, "show_on_landing": true}"#,
432 )
433 .await;
434 assert_eq!(
435 resp.status, 200,
436 "Create flagged non-changelog post failed: {}",
437 resp.text
438 );
439
440 // State 2: a flagged post on a non-changelog project is ignored by the
441 // landing reader (which filters by the changelog slug).
442 h.client.post_form("/logout", "").await;
443 let resp = h.client.get("/").await;
444 assert!(
445 !resp.text.contains("Last shipped:"),
446 "Landing-flagged post on a non-changelog project must not surface"
447 );
448 assert!(
449 !resp.text.contains("Off-topic note"),
450 "Non-changelog post title must not appear on the landing page"
451 );
452
453 // The changelog project with a landing-flagged, published post.
454 h.login("shipper", "password123").await;
455 let resp = h
456 .client
457 .post_form("/api/projects", "slug=changelog&title=Changelog")
458 .await;
459 let changelog: Value = resp.json();
460 let changelog_id = changelog["id"].as_str().unwrap();
461 h.client
462 .put_json(
463 &format!("/api/projects/{changelog_id}"),
464 r#"{"is_public": true}"#,
465 )
466 .await;
467 let resp = h
468 .client
469 .post_json(
470 &format!("/api/projects/{changelog_id}/blog"),
471 r#"{"title": "Shipped gallery widget", "body_markdown": "Body", "is_published": true, "show_on_landing": true}"#,
472 )
473 .await;
474 assert_eq!(
475 resp.status, 200,
476 "Create flagged changelog post failed: {}",
477 resp.text
478 );
479 let post: Value = resp.json();
480 let post_slug = post["slug"].as_str().unwrap();
481 assert_eq!(
482 post["show_on_landing"].as_bool(),
483 Some(true),
484 "Create response should echo the landing flag"
485 );
486
487 // State 3: the changelog post surfaces as the velocity line.
488 h.client.post_form("/logout", "").await;
489 let resp = h.client.get("/").await;
490 assert!(
491 resp.text.contains("Last shipped:"),
492 "Velocity line should appear once a changelog post is flagged: {}",
493 resp.text
494 );
495 assert!(
496 resp.text.contains("Shipped gallery widget"),
497 "Velocity line should carry the post title"
498 );
499 assert!(
500 resp.text.contains(&format!("/changelog/{post_slug}")),
501 "Velocity line should link to /changelog/{post_slug}"
502 );
503 }
504
505 /// The footer Changelog link is gated on the changelog project existing and
506 /// being public. Unpublished, the link is absent rather than pointing every
507 /// visitor at a 404; once published, it appears and resolves.
508 ///
509 /// Touches the process-global flag in `makenotwork::changelog`, so it restores
510 /// the default before returning: other tests in this binary render the same
511 /// footer.
512 #[tokio::test]
513 async fn footer_changelog_link_gated_on_published_project() {
514 let mut h = TestHarness::new().await;
515
516 // State 1: no changelog project, no link, and the route is a 404.
517 makenotwork::changelog::refresh(&h.db).await;
518 assert!(
519 !makenotwork::changelog::is_published(),
520 "Flag should be false with no changelog project"
521 );
522 let resp = h.client.get("/").await;
523 assert_eq!(resp.status, 200, "Landing should render: {}", resp.text);
524 assert!(
525 !resp.text.contains(r#"href="/changelog""#),
526 "Footer must not link to /changelog while the route 404s"
527 );
528 assert_eq!(
529 h.client.get("/changelog").await.status,
530 404,
531 "Route should 404 with no published changelog project"
532 );
533
534 // Publish the changelog project.
535 let user_id = h
536 .signup("logger", "logger@example.com", "password123")
537 .await;
538 h.grant_creator(user_id).await;
539 h.client.post_form("/logout", "").await;
540 h.login("logger", "password123").await;
541 let resp = h
542 .client
543 .post_form("/api/projects", "slug=changelog&title=Changelog")
544 .await;
545 let project: Value = resp.json();
546 let project_id = project["id"].as_str().unwrap();
547 h.client
548 .put_json(
549 &format!("/api/projects/{project_id}"),
550 r#"{"is_public": true}"#,
551 )
552 .await;
553 h.client.post_form("/logout", "").await;
554
555 // State 2: the next refresh brings the link back on its own.
556 makenotwork::changelog::refresh(&h.db).await;
557 assert!(
558 makenotwork::changelog::is_published(),
559 "Flag should be true once the changelog project is public"
560 );
561 let resp = h.client.get("/").await;
562 assert!(
563 resp.text.contains(r#"href="/changelog""#),
564 "Footer should link to /changelog once it resolves: {}",
565 resp.text
566 );
567 assert_eq!(
568 h.client.get("/changelog").await.status,
569 200,
570 "The linked route must resolve"
571 );
572
573 makenotwork::changelog::set_published_for_test(false);
574 }
575