Skip to main content

max / makenotwork

13.8 KB · 395 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.signup("blogger", "blogger@example.com", "password123").await;
12 h.grant_creator(user_id).await;
13 h.client.post_form("/logout", "").await;
14 h.login("blogger", "password123").await;
15
16 let resp = h
17 .client
18 .post_form("/api/projects", "slug=my-blog&title=My+Blog")
19 .await;
20 let project: Value = resp.json();
21 let project_id = project["id"].as_str().unwrap();
22
23 // Make project public
24 h.client
25 .put_json(
26 &format!("/api/projects/{}", project_id),
27 r#"{"is_public": true}"#,
28 )
29 .await;
30
31 // Create blog post
32 let resp = h
33 .client
34 .post_json(
35 &format!("/api/projects/{}/blog", project_id),
36 r#"{"title": "First Post", "body_markdown": "Hello **world**!", "is_published": false}"#,
37 )
38 .await;
39 assert!(
40 resp.status.is_success(),
41 "Create blog post failed: {} {}",
42 resp.status,
43 resp.text
44 );
45 let post: Value = resp.json();
46 let post_id = post["id"].as_str().unwrap();
47 let post_slug = post["slug"].as_str().unwrap();
48
49 // Publish the post
50 let resp = h
51 .client
52 .put_json(
53 &format!("/api/blog/{}", post_id),
54 &format!(
55 r#"{{"title": "First Post", "slug": "{}", "body_markdown": "Hello **world**!", "is_published": true}}"#,
56 post_slug
57 ),
58 )
59 .await;
60 assert!(
61 resp.status.is_success(),
62 "Publish blog post failed: {} {}",
63 resp.status,
64 resp.text
65 );
66
67 // Check blog page is accessible
68 let blog_url = format!("/p/my-blog/blog/{}", post_slug);
69 let resp = h.client.get(&blog_url).await;
70 assert_eq!(
71 resp.status, 200,
72 "Blog post page should be accessible at {}",
73 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.signup("cruduser", "cruduser@example.com", "password123").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/{}/blog", project_id),
115 r#"{"title": "Draft Post", "body_markdown": "Initial body", "is_published": false}"#,
116 )
117 .await;
118 assert!(resp.status.is_success(), "Create draft failed: {} {}", resp.status, resp.text);
119 let post: Value = resp.json();
120 let post_id = post["id"].as_str().unwrap();
121 let post_slug = post["slug"].as_str().unwrap();
122 assert_eq!(post["is_published"].as_bool(), Some(false));
123
124 // Read it back via GET /api/blog/{id}
125 let resp = h.client.get(&format!("/api/blog/{}", post_id)).await;
126 assert!(resp.status.is_success(), "Get blog post failed: {} {}", resp.status, resp.text);
127 let fetched: Value = resp.json();
128 assert_eq!(fetched["title"].as_str(), Some("Draft Post"));
129 assert_eq!(fetched["body_markdown"].as_str(), Some("Initial body"));
130 assert_eq!(fetched["is_published"].as_bool(), Some(false));
131
132 // Update title and body
133 let resp = h
134 .client
135 .put_json(
136 &format!("/api/blog/{}", post_id),
137 &format!(
138 r#"{{"title": "Updated Post", "slug": "{}", "body_markdown": "Updated body content", "is_published": false}}"#,
139 post_slug
140 ),
141 )
142 .await;
143 assert!(resp.status.is_success(), "Update blog post failed: {} {}", resp.status, resp.text);
144
145 // Verify changes persisted
146 let resp = h.client.get(&format!("/api/blog/{}", post_id)).await;
147 let fetched: Value = resp.json();
148 assert_eq!(fetched["title"].as_str(), Some("Updated Post"));
149 assert_eq!(fetched["body_markdown"].as_str(), Some("Updated body content"));
150
151 // Publish the post
152 let resp = h
153 .client
154 .put_json(
155 &format!("/api/blog/{}", post_id),
156 &format!(
157 r#"{{"title": "Updated Post", "slug": "{}", "body_markdown": "Updated body content", "is_published": true}}"#,
158 post_slug
159 ),
160 )
161 .await;
162 assert!(resp.status.is_success(), "Publish blog post failed: {} {}", resp.status, resp.text);
163
164 // Verify is_published
165 let resp = h.client.get(&format!("/api/blog/{}", post_id)).await;
166 let fetched: Value = resp.json();
167 assert_eq!(fetched["is_published"].as_bool(), Some(true));
168
169 // Delete the post
170 let resp = h.client.delete(&format!("/api/blog/{}", post_id)).await;
171 assert!(resp.status.is_success(), "Delete blog post failed: {} {}", resp.status, resp.text);
172
173 // Verify 404 after deletion
174 let resp = h.client.get(&format!("/api/blog/{}", post_id)).await;
175 assert_eq!(resp.status, 404, "Deleted blog post should return 404");
176 }
177
178 #[tokio::test]
179 async fn non_owner_cannot_edit_blog() {
180 let mut h = TestHarness::new().await;
181
182 // User A creates project + post
183 let user_a = h.signup("blogowner", "blogowner@example.com", "password123").await;
184 h.grant_creator(user_a).await;
185 h.client.post_form("/logout", "").await;
186 h.login("blogowner", "password123").await;
187
188 let resp = h
189 .client
190 .post_form("/api/projects", "slug=owner-blog&title=Owner+Blog")
191 .await;
192 let project: Value = resp.json();
193 let project_id = project["id"].as_str().unwrap();
194
195 let resp = h
196 .client
197 .post_json(
198 &format!("/api/projects/{}/blog", project_id),
199 r#"{"title": "Owner Post", "body_markdown": "Secret content", "is_published": false}"#,
200 )
201 .await;
202 assert!(resp.status.is_success(), "Create post failed: {}", resp.text);
203 let post: Value = resp.json();
204 let post_id = post["id"].as_str().unwrap();
205 let post_slug = post["slug"].as_str().unwrap();
206
207 // Log out user A, sign up user B
208 h.client.post_form("/logout", "").await;
209 let user_b = h.signup("intruder", "intruder@example.com", "password123").await;
210 h.grant_creator(user_b).await;
211 h.client.post_form("/logout", "").await;
212 h.login("intruder", "password123").await;
213
214 // User B tries to update user A's post
215 let resp = h
216 .client
217 .put_json(
218 &format!("/api/blog/{}", post_id),
219 &format!(
220 r#"{{"title": "Hacked", "slug": "{}", "body_markdown": "Hacked body", "is_published": true}}"#,
221 post_slug
222 ),
223 )
224 .await;
225 assert_eq!(resp.status, 403, "Non-owner update should be 403, got {}", resp.status);
226
227 // User B tries to delete user A's post
228 let resp = h.client.delete(&format!("/api/blog/{}", post_id)).await;
229 assert_eq!(resp.status, 403, "Non-owner delete should be 403, got {}", resp.status);
230 }
231
232 #[tokio::test]
233 async fn blog_post_list_respects_visibility() {
234 let mut h = TestHarness::new().await;
235
236 // Setup: creator with public project
237 let user_id = h.signup("listuser", "listuser@example.com", "password123").await;
238 h.grant_creator(user_id).await;
239 h.client.post_form("/logout", "").await;
240 h.login("listuser", "password123").await;
241
242 let resp = h
243 .client
244 .post_form("/api/projects", "slug=list-blog&title=List+Blog")
245 .await;
246 let project: Value = resp.json();
247 let project_id = project["id"].as_str().unwrap();
248
249 // Make project public
250 h.client
251 .put_json(
252 &format!("/api/projects/{}", project_id),
253 r#"{"is_public": true}"#,
254 )
255 .await;
256
257 // Create a published post
258 let resp = h
259 .client
260 .post_json(
261 &format!("/api/projects/{}/blog", project_id),
262 r#"{"title": "Public Post", "body_markdown": "Visible to all", "is_published": true}"#,
263 )
264 .await;
265 assert!(resp.status.is_success(), "Create public post failed: {}", resp.text);
266 let public_post: Value = resp.json();
267 let public_slug = public_post["slug"].as_str().unwrap();
268
269 // Create a draft post
270 let resp = h
271 .client
272 .post_json(
273 &format!("/api/projects/{}/blog", project_id),
274 r#"{"title": "Draft Post", "body_markdown": "Hidden from public", "is_published": false}"#,
275 )
276 .await;
277 assert!(resp.status.is_success(), "Create draft post failed: {}", resp.text);
278
279 // List via API (public endpoint) should show only the published post
280 let resp = h
281 .client
282 .get(&format!("/api/projects/{}/blog", project_id))
283 .await;
284 assert!(resp.status.is_success(), "List blog posts failed: {}", resp.text);
285 let list: Value = resp.json();
286 let posts = list["data"].as_array().unwrap();
287 assert_eq!(posts.len(), 1, "Public list should contain only 1 published post");
288 assert_eq!(posts[0]["title"].as_str(), Some("Public Post"));
289
290 // Verify the public post is accessible on the public page
291 let blog_url = format!("/p/list-blog/blog/{}", public_slug);
292 let resp = h.client.get(&blog_url).await;
293 assert_eq!(resp.status, 200, "Published post should be accessible at {}", blog_url);
294 assert!(resp.text.contains("Public Post"), "Page should contain the post title");
295 }
296
297 /// The landing "Last shipped" velocity line: suppressed with no eligible post,
298 /// surfaced for the most recent flagged changelog post, and never fed by
299 /// landing-flagged posts on non-changelog projects.
300 #[tokio::test]
301 async fn landing_velocity_line() {
302 let mut h = TestHarness::new().await;
303
304 // State 1: no eligible post on a fresh platform — line is suppressed.
305 let resp = h.client.get("/").await;
306 assert_eq!(resp.status, 200, "Landing should render: {}", resp.text);
307 assert!(
308 !resp.text.contains("Last shipped:"),
309 "Velocity line must be absent with zero eligible posts"
310 );
311
312 // Creator owns both a non-changelog project and the changelog project.
313 let user_id = h.signup("shipper", "shipper@example.com", "password123").await;
314 h.grant_creator(user_id).await;
315 h.client.post_form("/logout", "").await;
316 h.login("shipper", "password123").await;
317
318 // A non-changelog project with a landing-flagged, published post.
319 let resp = h
320 .client
321 .post_form("/api/projects", "slug=updates&title=Updates")
322 .await;
323 let updates: Value = resp.json();
324 let updates_id = updates["id"].as_str().unwrap();
325 h.client
326 .put_json(&format!("/api/projects/{}", updates_id), r#"{"is_public": true}"#)
327 .await;
328 let resp = h
329 .client
330 .post_json(
331 &format!("/api/projects/{}/blog", updates_id),
332 r#"{"title": "Off-topic note", "body_markdown": "Body", "is_published": true, "show_on_landing": true}"#,
333 )
334 .await;
335 assert!(resp.status.is_success(), "Create flagged non-changelog post failed: {}", resp.text);
336
337 // State 2: a flagged post on a non-changelog project is ignored by the
338 // landing reader (which filters by the changelog slug).
339 h.client.post_form("/logout", "").await;
340 let resp = h.client.get("/").await;
341 assert!(
342 !resp.text.contains("Last shipped:"),
343 "Landing-flagged post on a non-changelog project must not surface"
344 );
345 assert!(
346 !resp.text.contains("Off-topic note"),
347 "Non-changelog post title must not appear on the landing page"
348 );
349
350 // The changelog project with a landing-flagged, published post.
351 h.login("shipper", "password123").await;
352 let resp = h
353 .client
354 .post_form("/api/projects", "slug=changelog&title=Changelog")
355 .await;
356 let changelog: Value = resp.json();
357 let changelog_id = changelog["id"].as_str().unwrap();
358 h.client
359 .put_json(&format!("/api/projects/{}", changelog_id), r#"{"is_public": true}"#)
360 .await;
361 let resp = h
362 .client
363 .post_json(
364 &format!("/api/projects/{}/blog", changelog_id),
365 r#"{"title": "Shipped gallery widget", "body_markdown": "Body", "is_published": true, "show_on_landing": true}"#,
366 )
367 .await;
368 assert!(resp.status.is_success(), "Create flagged changelog post failed: {}", resp.text);
369 let post: Value = resp.json();
370 let post_slug = post["slug"].as_str().unwrap();
371 assert_eq!(
372 post["show_on_landing"].as_bool(),
373 Some(true),
374 "Create response should echo the landing flag"
375 );
376
377 // State 3: the changelog post surfaces as the velocity line.
378 h.client.post_form("/logout", "").await;
379 let resp = h.client.get("/").await;
380 assert!(
381 resp.text.contains("Last shipped:"),
382 "Velocity line should appear once a changelog post is flagged: {}",
383 resp.text
384 );
385 assert!(
386 resp.text.contains("Shipped gallery widget"),
387 "Velocity line should carry the post title"
388 );
389 assert!(
390 resp.text.contains(&format!("/changelog/{}", post_slug)),
391 "Velocity line should link to /changelog/{}",
392 post_slug
393 );
394 }
395