From bbeeda7268fb9703af44a092c5508334e20b27d5 Mon Sep 17 00:00:00 2001 From: thepra Date: Mon, 5 Oct 2026 04:35:23 +0200 Subject: [PATCH] A vote turned the other way replaces the first Lemmy turns an upvote into a downvote with a Dislike alone (and back with a Like), so the like stayed counted next to the downvote. Now an account has one vote on a post: a Dislike takes its like away, a Like its downvote. The Lemmy scenario's relayed votes were never failing for that reason only: Lemmy 1.0 sends what it queued every 30 seconds, and the check gave up after 30. It waits a minute now, and both votes are plain checks: 22 pass, the moderator's removal stays an expected failure (P7). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- CLAUDE.md | 3 +- PrivaPub.Tests/Federation/InteractionTests.cs | 28 ++++++++++++++++++- .../Inbox/Handlers/DislikeHandler.cs | 4 +++ .../Federation/Inbox/Handlers/LikeHandler.cs | 4 +++ tools/pasture/scenarios/lemmy.sh | 17 ++++------- 5 files changed, 43 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d937c11..e91cfab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -490,7 +490,8 @@ tools/pasture/run.sh down # removes e activity at `warn` (`LEMMY_LOG` sets `RUST_LOG`); the reason is in the 400's body. It answers our community's echo of its own activity and every bare `Announce{object}` 400 by design, and the echo is still needed (see `docs/INTEROP.md`, Lemmy). A new Lemmy never sends what it queued for a server before its send worker for that - server started, so the scenario waits for that worker (`lm_worker`) before its first follow. 20 checks; relayed votes and a moderator's removal are expected failures (P7). + server started, so the scenario waits for that worker (`lm_worker`) before its first follow, and sends what it queued every 30 seconds, so a vote takes up to a minute. + 22 checks; a moderator's removal is an expected failure (P7). - **Hollo (0.9.19):** Fedify's microblog server on the shared Postgres, set up through its web form (which checks `Origin` against `Host`, so the request names `hollo.test` without the port). It needs a 44-character `SECRET_KEY`, a media directory and a `themeColor`; statuses and votes go as JSON. Town only, no scenario. diff --git a/PrivaPub.Tests/Federation/InteractionTests.cs b/PrivaPub.Tests/Federation/InteractionTests.cs index 4a38b16..99b63af 100644 --- a/PrivaPub.Tests/Federation/InteractionTests.cs +++ b/PrivaPub.Tests/Federation/InteractionTests.cs @@ -95,17 +95,43 @@ namespace PrivaPub.Tests.Federation ["id"] = $"{Origin(carol)}/reactions/{Guid.NewGuid():N}", ["type"] = "EmojiReact", ["actor"] = carol.Id, ["object"] = post.ObjectURI, ["content"] = "🔥" }); + Assert.Equal(1, (await DB.Default.Find().OneAsync(post.ID, token)).FavouritesCount); await _harness.Deliver(carol, "/human-centipede", new JsonObject { ["id"] = $"{Origin(carol)}/dislikes/{Guid.NewGuid():N}", ["type"] = "Dislike", ["actor"] = carol.Id, ["object"] = post.ObjectURI }); + // the downvote counts, and takes the place of carol's like (one vote each, as Lemmy votes) var stored = await DB.Default.Find().OneAsync(post.ID, token); - Assert.Equal(1, stored.FavouritesCount); + Assert.Equal(0, stored.FavouritesCount); Assert.Equal(1, stored.DownvotesCount); Assert.True(await DB.Default.Find().Match(r => r.PostId == post.ID && r.ActorURI == carol.Id && r.Emoji == "🔥").ExecuteAnyAsync(token)); } + [Fact] + public async Task A_vote_turned_the_other_way_replaces_the_first_as_lemmy_sends_it() + { + var token = TestContext.Current.CancellationToken; + var (_, post) = await LocalPost(); + var voter = new RemoteActor(_harness.Peer, "voter"); + var other = new RemoteActor(_harness.Peer, "other"); + JsonObject Vote(RemoteActor actor, string type) => new() + { + ["id"] = $"{Origin(actor)}/{type.ToLowerInvariant()}s/{Guid.NewGuid():N}", ["type"] = type, ["actor"] = actor.Id, ["object"] = post.ObjectURI + }; + + await _harness.Deliver(other, "/human-centipede", Vote(other, "Like")); + await _harness.Deliver(voter, "/human-centipede", Vote(voter, "Dislike")); + await _harness.Deliver(voter, "/human-centipede", Vote(voter, "Like")); + var up = await DB.Default.Find().OneAsync(post.ID, token); + Assert.Equal((2, 0), (up.FavouritesCount, up.DownvotesCount)); + + await _harness.Deliver(voter, "/human-centipede", Vote(voter, "Dislike")); + var down = await DB.Default.Find().OneAsync(post.ID, token); + Assert.Equal((1, 1), (down.FavouritesCount, down.DownvotesCount)); + Assert.False(await DB.Default.Find().Match(f => f.PostId == post.ID && f.ActorURI == voter.Id).ExecuteAnyAsync(token)); + } + [Fact] public async Task A_circle_member_likes_a_circle_post_and_an_outsider_cannot() { diff --git a/PrivaPub/Federation/Inbox/Handlers/DislikeHandler.cs b/PrivaPub/Federation/Inbox/Handlers/DislikeHandler.cs index a0fcb6e..5dd42ae 100644 --- a/PrivaPub/Federation/Inbox/Handlers/DislikeHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/DislikeHandler.cs @@ -49,6 +49,10 @@ namespace PrivaPub.Federation.Inbox.Handlers } Arrival.Accept("stored"); await DB.Default.Update().MatchID(post.ID).Modify(b => b.Inc(p => p.DownvotesCount, 1)).ExecuteAsync(token); + // a vote replaces the other one: Lemmy turns an upvote into a downvote with this Dislike alone + if (await DB.Default.Find().Match(f => f.PostId == post.ID && f.ActorURI == actor.ActorURI).ExecuteFirstAsync(token) is { } liked + && (await DB.Default.DeleteAsync(liked.ID)).DeletedCount > 0) + await DB.Default.Update().MatchID(post.ID).Modify(b => b.Inc(p => p.FavouritesCount, -1)).ExecuteAsync(token); } } } diff --git a/PrivaPub/Federation/Inbox/Handlers/LikeHandler.cs b/PrivaPub/Federation/Inbox/Handlers/LikeHandler.cs index 835d767..4ac79d0 100644 --- a/PrivaPub/Federation/Inbox/Handlers/LikeHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/LikeHandler.cs @@ -59,6 +59,10 @@ namespace PrivaPub.Federation.Inbox.Handlers } Arrival.Accept("stored"); await DB.Default.Update().MatchID(post.ID).Modify(b => b.Inc(p => p.FavouritesCount, 1)).ExecuteAsync(token); + // a vote replaces the other one: Lemmy turns a downvote into an upvote with this Like alone + if (await DB.Default.Find().Match(d => d.PostId == post.ID && d.ActorURI == actor.ActorURI).ExecuteFirstAsync(token) is { } downvoted + && (await DB.Default.DeleteAsync(downvoted.ID)).DeletedCount > 0) + await DB.Default.Update().MatchID(post.ID).Modify(b => b.Inc(p => p.DownvotesCount, -1)).ExecuteAsync(token); if (!post.IsFederatedCopy) await Notifications.Add(post.GroupUserId, NotificationType.Favourite, actor.ID, actor.ActorURI, post.ID, token); } diff --git a/tools/pasture/scenarios/lemmy.sh b/tools/pasture/scenarios/lemmy.sh index b382b4b..0f4c6df 100644 --- a/tools/pasture/scenarios/lemmy.sh +++ b/tools/pasture/scenarios/lemmy.sh @@ -78,19 +78,14 @@ until_true 30 '[ "$(lm GET "comment/list?post_id=$lm_cats_id" | j "print(any(\"a && ok "alice's reply becomes a comment on Lemmy" || ko "PrivaPub reply missing as a Lemmy comment" echo " votes" -# Lemmy sends a vote only to the community, which relays it inside an Announce: P7 trusts those for the community's own posts. +# Lemmy sends a vote only to the community, which relays it inside an Announce (trusted since ed08f80); Lemmy 1.0 sends +# what it queued every 30 seconds, so a vote takes up to a minute lm POST post/like "{\"post_id\":$p_thread_on_lm,\"is_upvote\":true}" >/dev/null -if until_true 15 '[ "$(curl -s -H "$LAH" "$P/api/v1/statuses/$p_thread_id" | j "print(d[\"favourites_count\"])")" = "1" ]'; then - ok "Lemmy's upvote counts as a like on PrivaPub" -else - xf "Lemmy's upvote counts as a like on PrivaPub (votes relayed by a community are P7)" -fi +until_true 45 '[ "$(curl -s -H "$LAH" "$P/api/v1/statuses/$p_thread_id" | j "print(d[\"favourites_count\"])")" = "1" ]' \ + && ok "Lemmy's upvote counts as a like on PrivaPub" || ko "Lemmy's relayed upvote not counted" lm POST post/like "{\"post_id\":$p_thread_on_lm,\"is_upvote\":false}" >/dev/null -if until_true 15 '[ "$(curl -s -H "$LAH" "$P/api/v1/statuses/$p_thread_id" | j "print(d[\"favourites_count\"], ((d.get(\"privapub\") or {}).get(\"votes\") or {}).get(\"down\"))")" = "0 1" ]'; then - ok "Lemmy's change to a downvote reaches PrivaPub" -else - xf "Lemmy's change to a downvote reaches PrivaPub (votes relayed by a community are P7)" -fi +until_true 45 '[ "$(curl -s -H "$LAH" "$P/api/v1/statuses/$p_thread_id" | j "print(d[\"favourites_count\"], ((d.get(\"privapub\") or {}).get(\"votes\") or {}).get(\"down\"))")" = "0 1" ]' \ + && ok "Lemmy's change to a downvote reaches PrivaPub" || ko "Lemmy's change to a downvote not applied" curl -s -o /dev/null -X POST -H "$LAH" "$P/api/v1/statuses/$lm_cats_on_p/favourite" until_true 30 '[ "$(lm GET "post?id=$lm_cats_id" | j "print(d[\"post_view\"][\"post\"].get(\"upvotes\"))")" = "2" ]' && ok "alice's like is an upvote on Lemmy" || ko "like not counted as an upvote on Lemmy"