Skip to main content

max / makenotwork

14.6 KB · 499 lines History Blame Raw
1 //! Follow/unfollow: user follows, project follows, self-follow rejection, nonexistent target.
2
3 use crate::harness::TestHarness;
4 use serde_json::Value;
5
6 #[tokio::test]
7 async fn follow_unfollow_user() {
8 let mut h = TestHarness::new().await;
9
10 // Create two users
11 let user_a = h
12 .signup("follower", "follower@test.com", "password123")
13 .await;
14 h.client.post_form("/logout", "").await;
15 let user_b = h
16 .signup("followee", "followee@test.com", "password123")
17 .await;
18 h.client.post_form("/logout", "").await;
19
20 // Login as user A
21 h.login("follower", "password123").await;
22
23 // Follow user B
24 let resp = h
25 .client
26 .post_form(&format!("/api/follow/user/{}", *user_b), "")
27 .await;
28 assert!(
29 resp.status.is_success(),
30 "Follow user failed: {} {}",
31 resp.status,
32 resp.text
33 );
34
35 // Response is HTML (FollowButtonTemplate), check it contains follow state
36 assert!(
37 resp.text.contains('1') || resp.text.contains("follower_count"),
38 "Follow response should contain follower count"
39 );
40
41 // Verify in DB
42 let is_following: bool = sqlx::query_scalar(
43 "SELECT EXISTS(SELECT 1 FROM follows WHERE follower_id = $1 AND target_id = $2)",
44 )
45 .bind(user_a)
46 .bind(*user_b)
47 .fetch_one(&h.db)
48 .await
49 .unwrap();
50 assert!(is_following, "Should be following in DB");
51
52 // Unfollow user B
53 let resp = h
54 .client
55 .delete(&format!("/api/follow/user/{}", *user_b))
56 .await;
57 assert!(
58 resp.status.is_success(),
59 "Unfollow user failed: {} {}",
60 resp.status,
61 resp.text
62 );
63
64 // Verify unfollowed in DB
65 let is_following: bool = sqlx::query_scalar(
66 "SELECT EXISTS(SELECT 1 FROM follows WHERE follower_id = $1 AND target_id = $2)",
67 )
68 .bind(user_a)
69 .bind(*user_b)
70 .fetch_one(&h.db)
71 .await
72 .unwrap();
73 assert!(!is_following, "Should not be following after unfollow");
74 }
75
76 #[tokio::test]
77 async fn follow_unfollow_project() {
78 let mut h = TestHarness::new().await;
79
80 // Create creator with a public project
81 let creator_id = h
82 .signup("projcreator", "projcreator@test.com", "password123")
83 .await;
84 h.grant_creator(creator_id).await;
85 h.client.post_form("/logout", "").await;
86 h.login("projcreator", "password123").await;
87
88 let resp = h
89 .client
90 .post_form("/api/projects", "slug=followproj&title=Follow+Project")
91 .await;
92 assert!(
93 resp.status.is_success(),
94 "Create project failed: {} {}",
95 resp.status,
96 resp.text
97 );
98 let project: Value = resp.json();
99 let project_id = project["id"].as_str().unwrap().to_string();
100
101 // Make it public
102 let resp = h
103 .client
104 .put_json(
105 &format!("/api/projects/{project_id}"),
106 r#"{"visibility": "public"}"#,
107 )
108 .await;
109 assert!(
110 resp.status.is_success(),
111 "Make project public failed: {} {}",
112 resp.status,
113 resp.text
114 );
115
116 // Logout and create a different user to follow the project
117 h.client.post_form("/logout", "").await;
118 let follower_id = h
119 .signup("projfollower", "projfollower@test.com", "password123")
120 .await;
121
122 // Follow the project
123 let resp = h
124 .client
125 .post_form(&format!("/api/follow/project/{project_id}"), "")
126 .await;
127 assert!(
128 resp.status.is_success(),
129 "Follow project failed: {} {}",
130 resp.status,
131 resp.text
132 );
133
134 // Verify in DB
135 let count: i64 = sqlx::query_scalar(
136 "SELECT COUNT(*) FROM follows WHERE follower_id = $1 AND target_id = $2::uuid",
137 )
138 .bind(follower_id)
139 .bind(&project_id)
140 .fetch_one(&h.db)
141 .await
142 .unwrap();
143 assert_eq!(count, 1, "Should have one follow record");
144
145 // Unfollow
146 let resp = h
147 .client
148 .delete(&format!("/api/follow/project/{project_id}"))
149 .await;
150 assert!(
151 resp.status.is_success(),
152 "Unfollow project failed: {} {}",
153 resp.status,
154 resp.text
155 );
156
157 let count: i64 = sqlx::query_scalar(
158 "SELECT COUNT(*) FROM follows WHERE follower_id = $1 AND target_id = $2::uuid",
159 )
160 .bind(follower_id)
161 .bind(&project_id)
162 .fetch_one(&h.db)
163 .await
164 .unwrap();
165 assert_eq!(count, 0, "Follow record should be deleted");
166 }
167
168 #[tokio::test]
169 async fn follow_self_rejected() {
170 let mut h = TestHarness::new().await;
171 let user_id = h
172 .signup("selffollow", "selffollow@test.com", "password123")
173 .await;
174
175 // Try to follow yourself
176 let resp = h
177 .client
178 .post_form(&format!("/api/follow/user/{}", *user_id), "")
179 .await;
180 assert_eq!(
181 resp.status, 400,
182 "Self-follow should be rejected, got {} {}",
183 resp.status, resp.text
184 );
185 }
186
187 #[tokio::test]
188 async fn own_profile_hides_follow_button() {
189 let mut h = TestHarness::new().await;
190 h.signup("selfview", "selfview@test.com", "password123")
191 .await;
192
193 let resp = h.client.get("/u/selfview").await;
194 assert_eq!(resp.status, 200);
195 // Should NOT show a follow/unfollow button
196 assert!(
197 !resp.text.contains("follow-btn"),
198 "Own profile should not show follow button"
199 );
200 }
201
202 #[tokio::test]
203 async fn library_feed_huge_page_is_clamped_not_overflowing() {
204 // M-UX1: the feed tab's `?page=` must be clamped like every sibling
205 // paginator. A large in-range value (above the 1e9 clamp ceiling) should
206 // return a valid empty page, with the OFFSET bounded by the clamp rather
207 // than running a multi-billion-row deep-scan.
208 let mut h = TestHarness::new().await;
209 h.signup("feedpage", "feedpage@test.com", "password123")
210 .await;
211
212 let resp = h.client.get("/library/tabs/feed?page=4000000000").await;
213 assert_eq!(
214 resp.status, 200,
215 "large page should clamp to a valid empty page, got {} {}",
216 resp.status, resp.text
217 );
218 }
219
220 #[tokio::test]
221 async fn follow_nonexistent_rejected() {
222 let mut h = TestHarness::new().await;
223 let _user_id = h
224 .signup("ghostfollow", "ghostfollow@test.com", "password123")
225 .await;
226
227 // Try to follow a random UUID that doesn't exist
228 let fake_id = uuid::Uuid::new_v4();
229 let resp = h
230 .client
231 .post_form(&format!("/api/follow/user/{fake_id}"), "")
232 .await;
233 assert_eq!(
234 resp.status, 404,
235 "Following nonexistent user should 404, got {} {}",
236 resp.status, resp.text
237 );
238 }
239
240 // Tag follows and the feed
241 //
242 // The consequence, not the control. Following a tag is only worth anything if
243 // it changes what /feed returns, and that path had no coverage at all: the tag
244 // branch of the three UNION-CTE feed queries (db/follows.rs get_followed_items,
245 // get_followed_feed_items, count_followed_feed_items) was never exercised.
246
247 /// Publish a discoverable item and attach `slug` to it, creating the tag path.
248 /// Returns (item_id, leaf_tag_id).
249 async fn item_tagged_with(
250 h: &mut TestHarness,
251 username: &str,
252 title: &str,
253 slug: &str,
254 ) -> (String, uuid::Uuid) {
255 let setup = h.create_creator_with_item(username, "audio", 1000).await;
256 sqlx::query(
257 "UPDATE items SET title = $1, is_public = true, listed = true, \
258 scan_status = 'clean', deleted_at = NULL WHERE id = $2::uuid",
259 )
260 .bind(title)
261 .bind(&setup.item_id)
262 .execute(&h.db)
263 .await
264 .expect("publish item");
265 sqlx::query("UPDATE projects SET is_public = true WHERE id = $1::uuid")
266 .bind(&setup.project_id)
267 .execute(&h.db)
268 .await
269 .expect("publish project");
270
271 let mut parent: Option<uuid::Uuid> = None;
272 let segments: Vec<&str> = slug.split('.').collect();
273 for depth in 1..=segments.len() {
274 let path = segments[..depth].join(".");
275 let id: uuid::Uuid = sqlx::query_scalar(
276 "INSERT INTO tags (name, slug, path, parent_id) VALUES ($1, $2, $2, $3) \
277 ON CONFLICT (slug) DO UPDATE SET path = EXCLUDED.path RETURNING id",
278 )
279 .bind(segments[depth - 1])
280 .bind(&path)
281 .bind(parent)
282 .fetch_one(&h.db)
283 .await
284 .expect("upsert tag");
285 parent = Some(id);
286 }
287 let leaf = parent.expect("leaf tag id");
288 sqlx::query(
289 "INSERT INTO item_tags (item_id, tag_id) VALUES ($1::uuid, $2) ON CONFLICT DO NOTHING",
290 )
291 .bind(&setup.item_id)
292 .bind(leaf)
293 .execute(&h.db)
294 .await
295 .expect("attach tag");
296
297 (setup.item_id, leaf)
298 }
299
300 /// Following a tag puts its items in the feed, and unfollowing takes them out.
301 #[tokio::test]
302 async fn following_a_tag_changes_the_feed() {
303 let mut h = TestHarness::new().await;
304 let (_item, tag_id) = item_tagged_with(
305 &mut h,
306 "feedcreator",
307 "Tagged Release",
308 "audio.genre.electronic",
309 )
310 .await;
311 h.client.post_form("/logout", "").await;
312
313 h.signup("feedfollower", "feedfollower@test.com", "password123")
314 .await;
315
316 // Before: the tag is not followed, so the feed is empty.
317 let before = h.client.get("/feed").await;
318 assert!(before.status.is_success(), "{}", before.status);
319 assert!(
320 !before.text.contains("Tagged Release"),
321 "an unfollowed tag must not put items in the feed"
322 );
323
324 let follow = h
325 .client
326 .post_form(&format!("/api/follow/tag/{tag_id}"), "")
327 .await;
328 assert!(
329 follow.status.is_success(),
330 "follow failed: {}",
331 follow.status
332 );
333
334 // After: the item arrives purely because of the tag. The follower follows
335 // neither the creator nor the project.
336 let after = h.client.get("/feed").await;
337 assert!(after.status.is_success(), "{}", after.status);
338 assert!(
339 after.text.contains("Tagged Release"),
340 "a followed tag must put its items in the feed; body was: {}",
341 &after.text[..after.text.len().min(600)]
342 );
343
344 let unfollow = h.client.delete(&format!("/api/follow/tag/{tag_id}")).await;
345 assert!(
346 unfollow.status.is_success(),
347 "unfollow failed: {}",
348 unfollow.status
349 );
350
351 let removed = h.client.get("/feed").await;
352 assert!(
353 !removed.text.contains("Tagged Release"),
354 "unfollowing must take the items back out"
355 );
356 }
357
358 /// Resolve a tag id by slug. Ancestors are created by `item_tagged_with` on the
359 /// way down to the leaf, but only the leaf id is returned.
360 async fn tag_id_for(h: &TestHarness, slug: &str) -> uuid::Uuid {
361 sqlx::query_scalar("SELECT id FROM tags WHERE slug = $1")
362 .bind(slug)
363 .fetch_one(&h.db)
364 .await
365 .expect("tag by slug")
366 }
367
368 /// Following a BRANCH surfaces items tagged with a descendant leaf.
369 ///
370 /// This is the whole point of making following hierarchical, and nothing else
371 /// catches a broken subtree expansion: `item_tags` holds only directly-assigned
372 /// tags, and the write path enforces depth >= 3, so before this change a follow
373 /// on a depth-1 or depth-2 tag matched nothing forever.
374 #[tokio::test]
375 async fn following_a_parent_tag_surfaces_descendant_items() {
376 let mut h = TestHarness::new().await;
377 let (_item, leaf) = item_tagged_with(
378 &mut h,
379 "branchcreator",
380 "Deep Release",
381 "audio.genre.techno",
382 )
383 .await;
384 let branch = tag_id_for(&h, "audio.genre").await;
385 assert_ne!(
386 branch, leaf,
387 "the followed branch must not be the leaf itself"
388 );
389 h.client.post_form("/logout", "").await;
390
391 h.signup("branchfollower", "branchfollower@test.com", "password123")
392 .await;
393
394 let before = h.client.get("/feed").await;
395 assert!(
396 !before.text.contains("Deep Release"),
397 "an unfollowed branch must not put items in the feed"
398 );
399
400 let follow = h
401 .client
402 .post_form(&format!("/api/follow/tag/{branch}"), "")
403 .await;
404 assert!(
405 follow.status.is_success(),
406 "follow failed: {}",
407 follow.status
408 );
409
410 let after = h.client.get("/feed").await;
411 assert!(after.status.is_success(), "{}", after.status);
412 assert!(
413 after.text.contains("Deep Release"),
414 "following audio.genre must surface an item tagged audio.genre.techno; body was: {}",
415 &after.text[..after.text.len().min(600)]
416 );
417 }
418
419 /// The same expansion has to hold in `get_followed_items`, which backs the
420 /// signed RSS feed and is a separate query from the feed page's.
421 #[tokio::test]
422 async fn following_a_parent_tag_surfaces_descendant_items_in_rss() {
423 let mut h = TestHarness::new().await;
424 let (_item, _leaf) =
425 item_tagged_with(&mut h, "branchrss", "Deep Syndicated", "audio.genre.dub").await;
426 let branch = tag_id_for(&h, "audio").await;
427 h.client.post_form("/logout", "").await;
428
429 let follower = h
430 .signup("branchrssfollower", "branchrssf@test.com", "password123")
431 .await;
432 h.client
433 .post_form(&format!("/api/follow/tag/{branch}"), "")
434 .await;
435
436 let url = makenotwork::crypto::generate_feed_url(
437 "",
438 follower,
439 0,
440 "test-signing-secret-for-integration-tests",
441 );
442 let resp = h.client.get(&url).await;
443 assert!(
444 resp.status.is_success(),
445 "RSS feed: {} {}",
446 resp.status,
447 url
448 );
449 assert!(
450 resp.text.contains("Deep Syndicated"),
451 "following the depth-1 root must reach a depth-3 leaf's item over RSS"
452 );
453 }
454
455 /// A followed tag reaches the signed RSS feed too.
456 ///
457 /// The RSS handler uses get_followed_items, a different query from the /feed
458 /// page's get_followed_feed_items, so the tag branch has to hold in both. Its
459 /// doc comment says "followed users and projects", which undersells it.
460 #[tokio::test]
461 async fn following_a_tag_changes_the_rss_feed() {
462 let mut h = TestHarness::new().await;
463 let (_item, tag_id) = item_tagged_with(
464 &mut h,
465 "rsscreator",
466 "Syndicated Release",
467 "audio.genre.ambient",
468 )
469 .await;
470 h.client.post_form("/logout", "").await;
471
472 let follower = h
473 .signup("rssfollower", "rssfollower@test.com", "password123")
474 .await;
475 h.client
476 .post_form(&format!("/api/follow/tag/{tag_id}"), "")
477 .await;
478
479 // The feed URL is HMAC-signed so readers can fetch without cookies; mint one
480 // with the harness's known secret rather than scraping it out of a page.
481 let url = makenotwork::crypto::generate_feed_url(
482 "",
483 follower,
484 0,
485 "test-signing-secret-for-integration-tests",
486 );
487 let resp = h.client.get(&url).await;
488 assert!(
489 resp.status.is_success(),
490 "RSS feed: {} {}",
491 resp.status,
492 url
493 );
494 assert!(
495 resp.text.contains("Syndicated Release"),
496 "a followed tag must reach the RSS feed as well as the feed page"
497 );
498 }
499