Skip to main content

max / makenotwork

17.4 KB · 532 lines History Blame Raw
1 //! Mailing list infrastructure: default list creation, follow/unfollow subscription hooks,
2 //! unsubscribe asymmetry, idempotency, and content newsletter delivery (I4).
3
4 use crate::harness::TestHarness;
5 use serde_json::Value;
6
7 /// Helper: create a creator, create a project via API, return (creator_id, project_id).
8 async fn create_project(h: &mut TestHarness) -> (makenotwork::db::UserId, String) {
9 let creator_id = h.signup("mlcreator", "mlcreator@test.com", "password123").await;
10 h.grant_creator(creator_id).await;
11 h.client.post_form("/logout", "").await;
12 h.login("mlcreator", "password123").await;
13
14 let resp = h
15 .client
16 .post_form("/api/projects", "slug=mlproject&title=ML+Project")
17 .await;
18 assert!(resp.status.is_success(), "Create project failed: {} {}", resp.status, resp.text);
19 let project: Value = resp.json();
20 let project_id = project["id"].as_str().unwrap().to_string();
21
22 // Make it public
23 let resp = h
24 .client
25 .put_json(
26 &format!("/api/projects/{project_id}"),
27 r#"{"visibility": "public"}"#,
28 )
29 .await;
30 assert!(resp.status.is_success(), "Make project public failed: {} {}", resp.status, resp.text);
31
32 (creator_id, project_id)
33 }
34
35 #[tokio::test]
36 async fn project_creation_creates_default_lists() {
37 let mut h = TestHarness::new().await;
38 let (_creator_id, project_id) = create_project(&mut h).await;
39
40 // Verify two mailing lists were created (content + devlog)
41 let count: i64 = sqlx::query_scalar(
42 "SELECT COUNT(*) FROM mailing_lists WHERE project_id = $1::uuid",
43 )
44 .bind(&project_id)
45 .fetch_one(&h.db)
46 .await
47 .unwrap();
48 assert_eq!(count, 2, "Should have 2 default mailing lists (content + devlog)");
49
50 // Verify list types
51 let types: Vec<String> = sqlx::query_scalar(
52 "SELECT list_type FROM mailing_lists WHERE project_id = $1::uuid ORDER BY list_type",
53 )
54 .bind(&project_id)
55 .fetch_all(&h.db)
56 .await
57 .unwrap();
58 assert_eq!(types, vec!["content", "devlog"]);
59 }
60
61 #[tokio::test]
62 async fn follow_project_subscribes_to_content_list() {
63 let mut h = TestHarness::new().await;
64 let (_creator_id, project_id) = create_project(&mut h).await;
65
66 // Create a follower
67 h.client.post_form("/logout", "").await;
68 let follower_id = h.signup("mlfollower", "mlfollower@test.com", "password123").await;
69
70 // Follow the project
71 let resp = h
72 .client
73 .post_form(&format!("/api/follow/project/{project_id}"), "")
74 .await;
75 assert!(resp.status.is_success(), "Follow failed: {} {}", resp.status, resp.text);
76
77 // Verify subscriber row exists in content list
78 let sub_count: i64 = sqlx::query_scalar(
79 r#"
80 SELECT COUNT(*) FROM mailing_list_subscribers s
81 JOIN mailing_lists l ON l.id = s.list_id
82 WHERE l.project_id = $1::uuid AND l.list_type = 'content' AND s.user_id = $2
83 "#,
84 )
85 .bind(&project_id)
86 .bind(follower_id)
87 .fetch_one(&h.db)
88 .await
89 .unwrap();
90 assert_eq!(sub_count, 1, "Follower should be subscribed to content list");
91 }
92
93 #[tokio::test]
94 async fn unfollow_project_removes_from_all_lists() {
95 let mut h = TestHarness::new().await;
96 let (_creator_id, project_id) = create_project(&mut h).await;
97
98 // Create and login as follower
99 h.client.post_form("/logout", "").await;
100 let follower_id = h.signup("mlfollower2", "mlfollower2@test.com", "password123").await;
101
102 // Follow (auto-subscribes to content list)
103 let resp = h
104 .client
105 .post_form(&format!("/api/follow/project/{project_id}"), "")
106 .await;
107 assert!(resp.status.is_success());
108
109 // Also manually subscribe to devlog list
110 let devlog_id: uuid::Uuid = sqlx::query_scalar(
111 "SELECT id FROM mailing_lists WHERE project_id = $1::uuid AND list_type = 'devlog'",
112 )
113 .bind(&project_id)
114 .fetch_one(&h.db)
115 .await
116 .unwrap();
117 sqlx::query("INSERT INTO mailing_list_subscribers (list_id, user_id) VALUES ($1, $2)")
118 .bind(devlog_id)
119 .bind(follower_id)
120 .execute(&h.db)
121 .await
122 .unwrap();
123
124 // Verify 2 subscriptions exist
125 let sub_count: i64 = sqlx::query_scalar(
126 r#"
127 SELECT COUNT(*) FROM mailing_list_subscribers s
128 JOIN mailing_lists l ON l.id = s.list_id
129 WHERE l.project_id = $1::uuid AND s.user_id = $2
130 "#,
131 )
132 .bind(&project_id)
133 .bind(follower_id)
134 .fetch_one(&h.db)
135 .await
136 .unwrap();
137 assert_eq!(sub_count, 2, "Should be subscribed to both lists before unfollow");
138
139 // Unfollow the project
140 let resp = h
141 .client
142 .delete(&format!("/api/follow/project/{project_id}"))
143 .await;
144 assert!(resp.status.is_success(), "Unfollow failed: {} {}", resp.status, resp.text);
145
146 // Verify all subscriptions removed
147 let sub_count: i64 = sqlx::query_scalar(
148 r#"
149 SELECT COUNT(*) FROM mailing_list_subscribers s
150 JOIN mailing_lists l ON l.id = s.list_id
151 WHERE l.project_id = $1::uuid AND s.user_id = $2
152 "#,
153 )
154 .bind(&project_id)
155 .bind(follower_id)
156 .fetch_one(&h.db)
157 .await
158 .unwrap();
159 assert_eq!(sub_count, 0, "All subscriptions should be removed after unfollow");
160 }
161
162 #[tokio::test]
163 async fn unsubscribe_removes_from_list_but_keeps_follow() {
164 let mut h = TestHarness::new().await;
165 let (_creator_id, project_id) = create_project(&mut h).await;
166
167 // Create and login as follower
168 h.client.post_form("/logout", "").await;
169 let follower_id = h.signup("mlfollower3", "mlfollower3@test.com", "password123").await;
170
171 // Follow (auto-subscribes to content list)
172 let resp = h
173 .client
174 .post_form(&format!("/api/follow/project/{project_id}"), "")
175 .await;
176 assert!(resp.status.is_success());
177
178 // Directly unsubscribe from content list via DB
179 let content_list_id: uuid::Uuid = sqlx::query_scalar(
180 "SELECT id FROM mailing_lists WHERE project_id = $1::uuid AND list_type = 'content'",
181 )
182 .bind(&project_id)
183 .fetch_one(&h.db)
184 .await
185 .unwrap();
186
187 let deleted = sqlx::query(
188 "DELETE FROM mailing_list_subscribers WHERE list_id = $1 AND user_id = $2",
189 )
190 .bind(content_list_id)
191 .bind(follower_id)
192 .execute(&h.db)
193 .await
194 .unwrap();
195 assert_eq!(deleted.rows_affected(), 1, "Should have deleted one subscription");
196
197 // Verify follow relationship still exists
198 let is_following: bool = sqlx::query_scalar(
199 "SELECT EXISTS(SELECT 1 FROM follows WHERE follower_id = $1 AND target_id = $2::uuid)",
200 )
201 .bind(follower_id)
202 .bind(&project_id)
203 .fetch_one(&h.db)
204 .await
205 .unwrap();
206 assert!(is_following, "Follow should still exist after mailing list unsubscribe");
207 }
208
209 #[tokio::test]
210 async fn subscribe_idempotent() {
211 let mut h = TestHarness::new().await;
212 let (_creator_id, project_id) = create_project(&mut h).await;
213
214 // Create and login as follower
215 h.client.post_form("/logout", "").await;
216 let follower_id = h.signup("mlfollower4", "mlfollower4@test.com", "password123").await;
217
218 // Follow twice (both should succeed without error)
219 let resp = h
220 .client
221 .post_form(&format!("/api/follow/project/{project_id}"), "")
222 .await;
223 assert!(resp.status.is_success());
224
225 let resp = h
226 .client
227 .post_form(&format!("/api/follow/project/{project_id}"), "")
228 .await;
229 assert!(resp.status.is_success());
230
231 // Verify exactly one subscriber row
232 let sub_count: i64 = sqlx::query_scalar(
233 r#"
234 SELECT COUNT(*) FROM mailing_list_subscribers s
235 JOIN mailing_lists l ON l.id = s.list_id
236 WHERE l.project_id = $1::uuid AND l.list_type = 'content' AND s.user_id = $2
237 "#,
238 )
239 .bind(&project_id)
240 .bind(follower_id)
241 .fetch_one(&h.db)
242 .await
243 .unwrap();
244 assert_eq!(sub_count, 1, "Should have exactly one subscriber row despite double follow");
245 }
246
247 #[tokio::test]
248 async fn default_lists_idempotent() {
249 let mut h = TestHarness::new().await;
250 let (_creator_id, project_id) = create_project(&mut h).await;
251
252 // Insert duplicate lists via SQL with ON CONFLICT — should not error
253 sqlx::query(
254 r#"
255 INSERT INTO mailing_lists (project_id, list_type, name)
256 VALUES ($1::uuid, 'content', 'Duplicate Content')
257 ON CONFLICT (project_id, list_type) DO UPDATE SET name = EXCLUDED.name
258 "#,
259 )
260 .bind(&project_id)
261 .execute(&h.db)
262 .await
263 .expect("ON CONFLICT should handle duplicate list creation");
264
265 // Still exactly 2 lists (content was upserted, devlog unchanged)
266 let count: i64 = sqlx::query_scalar(
267 "SELECT COUNT(*) FROM mailing_lists WHERE project_id = $1::uuid",
268 )
269 .bind(&project_id)
270 .fetch_one(&h.db)
271 .await
272 .unwrap();
273 assert_eq!(count, 2, "Should still have exactly 2 lists after idempotent insert");
274 }
275
276 // =============================================================================
277 // I4: Content Newsletter Delivery
278 // =============================================================================
279
280 /// Helper: create a project with a follower subscribed to the content mailing list.
281 /// Returns (creator_id, project_id, follower_id).
282 async fn setup_project_with_subscriber(
283 h: &mut TestHarness,
284 ) -> (makenotwork::db::UserId, String, makenotwork::db::UserId) {
285 let creator_id = h.signup("i4creator", "i4creator@test.com", "password123").await;
286 h.grant_creator(creator_id).await;
287 h.client.post_form("/logout", "").await;
288 h.login("i4creator", "password123").await;
289
290 let resp = h
291 .client
292 .post_form("/api/projects", "slug=i4project&title=I4+Project")
293 .await;
294 assert!(resp.status.is_success(), "Create project failed: {} {}", resp.status, resp.text);
295 let project: Value = resp.json();
296 let project_id = project["id"].as_str().unwrap().to_string();
297
298 // Make project public
299 let resp = h
300 .client
301 .put_json(
302 &format!("/api/projects/{project_id}"),
303 r#"{"visibility": "public"}"#,
304 )
305 .await;
306 assert!(resp.status.is_success());
307
308 // Create a follower and follow the project (auto-subscribes to content list)
309 h.client.post_form("/logout", "").await;
310 let follower_id = h.signup("i4follower", "i4follower@test.com", "password123").await;
311 // Verify email so they'd receive announcements
312 sqlx::query("UPDATE users SET email_verified = true WHERE id = $1")
313 .bind(follower_id)
314 .execute(&h.db)
315 .await
316 .unwrap();
317
318 let resp = h
319 .client
320 .post_form(&format!("/api/follow/project/{project_id}"), "")
321 .await;
322 assert!(resp.status.is_success(), "Follow failed: {} {}", resp.status, resp.text);
323
324 (creator_id, project_id, follower_id)
325 }
326
327 #[tokio::test]
328 async fn item_release_emails_use_mailing_list() {
329 let mut h = TestHarness::new().await;
330 let (_creator_id, project_id, follower_id) = setup_project_with_subscriber(&mut h).await;
331
332 // Switch back to creator
333 h.client.post_form("/logout", "").await;
334 h.login("i4creator", "password123").await;
335
336 // Create an item
337 let resp = h
338 .client
339 .post_form(
340 &format!("/api/projects/{project_id}/items"),
341 "title=Test+Item&item_type=digital",
342 )
343 .await;
344 assert!(resp.status.is_success(), "Create item failed: {} {}", resp.status, resp.text);
345 let item: Value = resp.json();
346 let item_id = item["id"].as_str().unwrap().to_string();
347
348 // Publish the item
349 let resp = h
350 .client
351 .put_form(
352 &format!("/api/items/{item_id}"),
353 "is_public=true",
354 )
355 .await;
356 assert!(resp.status.is_success(), "Publish item failed: {} {}", resp.status, resp.text);
357
358 // Verify release_announced_at is set
359 let announced: bool = sqlx::query_scalar(
360 "SELECT release_announced_at IS NOT NULL FROM items WHERE id = $1::uuid",
361 )
362 .bind(&item_id)
363 .fetch_one(&h.db)
364 .await
365 .unwrap();
366 assert!(announced, "release_announced_at should be set after publishing");
367
368 // Verify the follower is a subscriber on the content mailing list
369 let sub_count: i64 = sqlx::query_scalar(
370 r#"
371 SELECT COUNT(*) FROM mailing_list_subscribers s
372 JOIN mailing_lists l ON l.id = s.list_id
373 WHERE l.project_id = $1::uuid AND l.list_type = 'content' AND s.user_id = $2
374 "#,
375 )
376 .bind(&project_id)
377 .bind(follower_id)
378 .fetch_one(&h.db)
379 .await
380 .unwrap();
381 assert_eq!(sub_count, 1, "Follower should be on content mailing list");
382
383 // Verify idempotency: trying to clear release_announced_at and re-announce
384 // should demonstrate that the column is set (no row affected)
385 let rows_affected = sqlx::query(
386 "UPDATE items SET release_announced_at = NOW() WHERE id = $1::uuid AND release_announced_at IS NULL",
387 )
388 .bind(&item_id)
389 .execute(&h.db)
390 .await
391 .unwrap()
392 .rows_affected();
393 assert_eq!(rows_affected, 0, "Second mark should be a no-op (already announced)");
394 }
395
396 #[tokio::test]
397 async fn blog_post_announcement_sends_emails() {
398 let mut h = TestHarness::new().await;
399 let (_creator_id, project_id, _follower_id) = setup_project_with_subscriber(&mut h).await;
400
401 // Switch back to creator
402 h.client.post_form("/logout", "").await;
403 h.login("i4creator", "password123").await;
404
405 // Create a published blog post
406 let resp = h
407 .client
408 .post_json(
409 &format!("/api/projects/{project_id}/blog"),
410 r#"{"title": "Test Blog Post", "body_markdown": "Hello world", "is_published": true}"#,
411 )
412 .await;
413 assert!(resp.status.is_success(), "Create blog post failed: {} {}", resp.status, resp.text);
414 let post: Value = resp.json();
415 let post_id = post["id"].as_str().unwrap().to_string();
416
417 // Verify release_announced_at is set on the blog post
418 let announced: bool = sqlx::query_scalar(
419 "SELECT release_announced_at IS NOT NULL FROM blog_posts WHERE id = $1::uuid",
420 )
421 .bind(&post_id)
422 .fetch_one(&h.db)
423 .await
424 .unwrap();
425 assert!(announced, "release_announced_at should be set after publishing blog post");
426
427 // Verify idempotency: second mark is a no-op
428 let rows_affected = sqlx::query(
429 "UPDATE blog_posts SET release_announced_at = NOW() WHERE id = $1::uuid AND release_announced_at IS NULL",
430 )
431 .bind(&post_id)
432 .execute(&h.db)
433 .await
434 .unwrap()
435 .rows_affected();
436 assert_eq!(rows_affected, 0, "Second mark should be a no-op (already announced)");
437 }
438
439 #[tokio::test]
440 async fn web_only_item_skips_email() {
441 let mut h = TestHarness::new().await;
442 let (_creator_id, project_id, _follower_id) = setup_project_with_subscriber(&mut h).await;
443
444 // Switch back to creator
445 h.client.post_form("/logout", "").await;
446 h.login("i4creator", "password123").await;
447
448 // Create an item
449 let resp = h
450 .client
451 .post_form(
452 &format!("/api/projects/{project_id}/items"),
453 "title=WebOnly+Item&item_type=digital",
454 )
455 .await;
456 assert!(resp.status.is_success());
457 let item: Value = resp.json();
458 let item_id = item["id"].as_str().unwrap().to_string();
459
460 // Set web_only=true and publish
461 let resp = h
462 .client
463 .put_form(
464 &format!("/api/items/{item_id}"),
465 "is_public=true&web_only=true",
466 )
467 .await;
468 assert!(resp.status.is_success(), "Publish web_only item failed: {} {}", resp.status, resp.text);
469
470 // Verify web_only is set
471 let web_only: bool = sqlx::query_scalar(
472 "SELECT web_only FROM items WHERE id = $1::uuid",
473 )
474 .bind(&item_id)
475 .fetch_one(&h.db)
476 .await
477 .unwrap();
478 assert!(web_only, "web_only should be true");
479
480 // Verify release_announced_at IS set (the mark happens before the web_only check)
481 let announced: bool = sqlx::query_scalar(
482 "SELECT release_announced_at IS NOT NULL FROM items WHERE id = $1::uuid",
483 )
484 .bind(&item_id)
485 .fetch_one(&h.db)
486 .await
487 .unwrap();
488 assert!(announced, "release_announced_at should still be set (idempotent guard)");
489 }
490
491 #[tokio::test]
492 async fn web_only_blog_post_skips_email() {
493 let mut h = TestHarness::new().await;
494 let (_creator_id, project_id, _follower_id) = setup_project_with_subscriber(&mut h).await;
495
496 // Switch back to creator
497 h.client.post_form("/logout", "").await;
498 h.login("i4creator", "password123").await;
499
500 // Create a published web_only blog post
501 let resp = h
502 .client
503 .post_json(
504 &format!("/api/projects/{project_id}/blog"),
505 r#"{"title": "Web Only Post", "body_markdown": "Silent post", "is_published": true, "web_only": true}"#,
506 )
507 .await;
508 assert!(resp.status.is_success(), "Create web_only blog post failed: {} {}", resp.status, resp.text);
509 let post: Value = resp.json();
510 let post_id = post["id"].as_str().unwrap().to_string();
511
512 // Verify web_only is set
513 let web_only: bool = sqlx::query_scalar(
514 "SELECT web_only FROM blog_posts WHERE id = $1::uuid",
515 )
516 .bind(&post_id)
517 .fetch_one(&h.db)
518 .await
519 .unwrap();
520 assert!(web_only, "web_only should be true on blog post");
521
522 // Verify release_announced_at IS set (idempotent guard still fires)
523 let announced: bool = sqlx::query_scalar(
524 "SELECT release_announced_at IS NOT NULL FROM blog_posts WHERE id = $1::uuid",
525 )
526 .bind(&post_id)
527 .fetch_one(&h.db)
528 .await
529 .unwrap();
530 assert!(announced, "release_announced_at should still be set for web_only post");
531 }
532