max / makenotwork
1 file changed,
+139 insertions,
-0 deletions
| @@ -233,3 +233,142 @@ async fn follow_nonexistent_rejected() { | |||
| 233 | 233 | resp.status, resp.text | |
| 234 | 234 | ); | |
| 235 | 235 | } | |
| 236 | + | ||
| 237 | + | // --------------------------------------------------------------------------- | |
| 238 | + | // Tag follows and the feed | |
| 239 | + | // | |
| 240 | + | // The consequence, not the control. Following a tag is only worth anything if | |
| 241 | + | // it changes what /feed returns, and that path had no coverage at all: the tag | |
| 242 | + | // branch of the three UNION-CTE feed queries (db/follows.rs get_followed_items, | |
| 243 | + | // get_followed_feed_items, count_followed_feed_items) was never exercised. | |
| 244 | + | // --------------------------------------------------------------------------- | |
| 245 | + | ||
| 246 | + | /// Publish a discoverable item and attach `slug` to it, creating the tag path. | |
| 247 | + | /// Returns (item_id, leaf_tag_id). | |
| 248 | + | async fn item_tagged_with( | |
| 249 | + | h: &mut TestHarness, | |
| 250 | + | username: &str, | |
| 251 | + | title: &str, | |
| 252 | + | slug: &str, | |
| 253 | + | ) -> (String, uuid::Uuid) { | |
| 254 | + | let setup = h.create_creator_with_item(username, "audio", 1000).await; | |
| 255 | + | sqlx::query( | |
| 256 | + | "UPDATE items SET title = $1, is_public = true, listed = true, \ | |
| 257 | + | scan_status = 'clean', deleted_at = NULL WHERE id = $2::uuid", | |
| 258 | + | ) | |
| 259 | + | .bind(title) | |
| 260 | + | .bind(&setup.item_id) | |
| 261 | + | .execute(&h.db) | |
| 262 | + | .await | |
| 263 | + | .expect("publish item"); | |
| 264 | + | sqlx::query("UPDATE projects SET is_public = true WHERE id = $1::uuid") | |
| 265 | + | .bind(&setup.project_id) | |
| 266 | + | .execute(&h.db) | |
| 267 | + | .await | |
| 268 | + | .expect("publish project"); | |
| 269 | + | ||
| 270 | + | let mut parent: Option<uuid::Uuid> = None; | |
| 271 | + | let segments: Vec<&str> = slug.split('.').collect(); | |
| 272 | + | for depth in 1..=segments.len() { | |
| 273 | + | let path = segments[..depth].join("."); | |
| 274 | + | let id: uuid::Uuid = sqlx::query_scalar( | |
| 275 | + | "INSERT INTO tags (name, slug, path, parent_id) VALUES ($1, $2, $2, $3) \ | |
| 276 | + | ON CONFLICT (slug) DO UPDATE SET path = EXCLUDED.path RETURNING id", | |
| 277 | + | ) | |
| 278 | + | .bind(segments[depth - 1]) | |
| 279 | + | .bind(&path) | |
| 280 | + | .bind(parent) | |
| 281 | + | .fetch_one(&h.db) | |
| 282 | + | .await | |
| 283 | + | .expect("upsert tag"); | |
| 284 | + | parent = Some(id); | |
| 285 | + | } | |
| 286 | + | let leaf = parent.expect("leaf tag id"); | |
| 287 | + | sqlx::query( | |
| 288 | + | "INSERT INTO item_tags (item_id, tag_id) VALUES ($1::uuid, $2) ON CONFLICT DO NOTHING", | |
| 289 | + | ) | |
| 290 | + | .bind(&setup.item_id) | |
| 291 | + | .bind(leaf) | |
| 292 | + | .execute(&h.db) | |
| 293 | + | .await | |
| 294 | + | .expect("attach tag"); | |
| 295 | + | ||
| 296 | + | (setup.item_id, leaf) | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | /// Following a tag puts its items in the feed, and unfollowing takes them out. | |
| 300 | + | #[tokio::test] | |
| 301 | + | async fn following_a_tag_changes_the_feed() { | |
| 302 | + | let mut h = TestHarness::new().await; | |
| 303 | + | let (_item, tag_id) = | |
| 304 | + | item_tagged_with(&mut h, "feedcreator", "Tagged Release", "audio.genre.electronic").await; | |
| 305 | + | h.client.post_form("/logout", "").await; | |
| 306 | + | ||
| 307 | + | h.signup("feedfollower", "feedfollower@test.com", "password123").await; | |
| 308 | + | ||
| 309 | + | // Before: the tag is not followed, so the feed is empty. | |
| 310 | + | let before = h.client.get("/feed").await; | |
| 311 | + | assert!(before.status.is_success(), "{}", before.status); | |
| 312 | + | assert!( | |
| 313 | + | !before.text.contains("Tagged Release"), | |
| 314 | + | "an unfollowed tag must not put items in the feed" | |
| 315 | + | ); | |
| 316 | + | ||
| 317 | + | let follow = h | |
| 318 | + | .client | |
| 319 | + | .post_form(&format!("/api/follow/tag/{tag_id}"), "") | |
| 320 | + | .await; | |
| 321 | + | assert!(follow.status.is_success(), "follow failed: {}", follow.status); | |
| 322 | + | ||
| 323 | + | // After: the item arrives purely because of the tag. The follower follows | |
| 324 | + | // neither the creator nor the project. | |
| 325 | + | let after = h.client.get("/feed").await; | |
| 326 | + | assert!(after.status.is_success(), "{}", after.status); | |
| 327 | + | assert!( | |
| 328 | + | after.text.contains("Tagged Release"), | |
| 329 | + | "a followed tag must put its items in the feed; body was: {}", | |
| 330 | + | &after.text[..after.text.len().min(600)] | |
| 331 | + | ); | |
| 332 | + | ||
| 333 | + | let unfollow = h.client.delete(&format!("/api/follow/tag/{tag_id}")).await; | |
| 334 | + | assert!(unfollow.status.is_success(), "unfollow failed: {}", unfollow.status); | |
| 335 | + | ||
| 336 | + | let removed = h.client.get("/feed").await; | |
| 337 | + | assert!( | |
| 338 | + | !removed.text.contains("Tagged Release"), | |
| 339 | + | "unfollowing must take the items back out" | |
| 340 | + | ); | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | /// A followed tag reaches the signed RSS feed too. | |
| 344 | + | /// | |
| 345 | + | /// The RSS handler uses get_followed_items, a different query from the /feed | |
| 346 | + | /// page's get_followed_feed_items, so the tag branch has to hold in both. Its | |
| 347 | + | /// doc comment says "followed users and projects", which undersells it. | |
| 348 | + | #[tokio::test] | |
| 349 | + | async fn following_a_tag_changes_the_rss_feed() { | |
| 350 | + | let mut h = TestHarness::new().await; | |
| 351 | + | let (_item, tag_id) = | |
| 352 | + | item_tagged_with(&mut h, "rsscreator", "Syndicated Release", "audio.genre.ambient").await; | |
| 353 | + | h.client.post_form("/logout", "").await; | |
| 354 | + | ||
| 355 | + | let follower = h.signup("rssfollower", "rssfollower@test.com", "password123").await; | |
| 356 | + | h.client | |
| 357 | + | .post_form(&format!("/api/follow/tag/{tag_id}"), "") | |
| 358 | + | .await; | |
| 359 | + | ||
| 360 | + | // The feed URL is HMAC-signed so readers can fetch without cookies; mint one | |
| 361 | + | // with the harness's known secret rather than scraping it out of a page. | |
| 362 | + | let url = makenotwork::crypto::generate_feed_url( | |
| 363 | + | "", | |
| 364 | + | follower, | |
| 365 | + | 0, | |
| 366 | + | "test-signing-secret-for-integration-tests", | |
| 367 | + | ); | |
| 368 | + | let resp = h.client.get(&url).await; | |
| 369 | + | assert!(resp.status.is_success(), "RSS feed: {} {}", resp.status, url); | |
| 370 | + | assert!( | |
| 371 | + | resp.text.contains("Syndicated Release"), | |
| 372 | + | "a followed tag must reach the RSS feed as well as the feed page" | |
| 373 | + | ); | |
| 374 | + | } |