Skip to main content

max / makenotwork

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