Skip to main content

max / makenotwork

Cover the consequence of following a tag, not just the control Following a tag is only worth anything if it changes what the feed returns, and that was the untested half. The tag branch of the three UNION-CTE feed queries had no coverage at all, which is why removing the follow control earlier broke nothing visible. Two tests, one per query family, because the feed page and the RSS feed use different queries: get_followed_feed_items for /feed and get_followed_items for the signed RSS URL. Each asserts absence before the follow, presence after, and absence again after unfollowing, so the item's arrival is attributable to the tag alone: the follower follows neither the creator nor the project. Both were verified red before being left green, by disabling the tag branch in all three queries. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-18 20:33 UTC
Commit: 9b6104d4d3ebfd9094e61144b6f5fbe4c4d74d32
Parent: 9216735
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 + }