Skip to main content

max / makenotwork

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