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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-05 04:35:23 +02:00
1 parent 325f7605f2
commit bbeeda7268
5 files changed
+43 -13

No files matched your search

+2 -1
View File
@@ -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 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 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 `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 - **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 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. `SECRET_KEY`, a media directory and a `themeColor`; statuses and votes go as JSON. Town only, no scenario.
+27 -1
View File
@@ -95,17 +95,43 @@ namespace PrivaPub.Tests.Federation
["id"] = $"{Origin(carol)}/reactions/{Guid.NewGuid():N}", ["type"] = "EmojiReact", ["actor"] = carol.Id, ["object"] = post.ObjectURI, ["id"] = $"{Origin(carol)}/reactions/{Guid.NewGuid():N}", ["type"] = "EmojiReact", ["actor"] = carol.Id, ["object"] = post.ObjectURI,
["content"] = "🔥" ["content"] = "🔥"
}); });
Assert.Equal(1, (await DB.Default.Find<Post>().OneAsync(post.ID, token)).FavouritesCount);
await _harness.Deliver(carol, "/human-centipede", new JsonObject await _harness.Deliver(carol, "/human-centipede", new JsonObject
{ {
["id"] = $"{Origin(carol)}/dislikes/{Guid.NewGuid():N}", ["type"] = "Dislike", ["actor"] = carol.Id, ["object"] = post.ObjectURI ["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<Post>().OneAsync(post.ID, token); var stored = await DB.Default.Find<Post>().OneAsync(post.ID, token);
Assert.Equal(1, stored.FavouritesCount); Assert.Equal(0, stored.FavouritesCount);
Assert.Equal(1, stored.DownvotesCount); Assert.Equal(1, stored.DownvotesCount);
Assert.True(await DB.Default.Find<Reaction>().Match(r => r.PostId == post.ID && r.ActorURI == carol.Id && r.Emoji == "🔥").ExecuteAnyAsync(token)); Assert.True(await DB.Default.Find<Reaction>().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<Post>().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<Post>().OneAsync(post.ID, token);
Assert.Equal((1, 1), (down.FavouritesCount, down.DownvotesCount));
Assert.False(await DB.Default.Find<Favourite>().Match(f => f.PostId == post.ID && f.ActorURI == voter.Id).ExecuteAnyAsync(token));
}
[Fact] [Fact]
public async Task A_circle_member_likes_a_circle_post_and_an_outsider_cannot() public async Task A_circle_member_likes_a_circle_post_and_an_outsider_cannot()
{ {
@@ -49,6 +49,10 @@ namespace PrivaPub.Federation.Inbox.Handlers
} }
Arrival.Accept("stored"); Arrival.Accept("stored");
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(b => b.Inc(p => p.DownvotesCount, 1)).ExecuteAsync(token); await DB.Default.Update<PostEntity>().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<Favourite>().Match(f => f.PostId == post.ID && f.ActorURI == actor.ActorURI).ExecuteFirstAsync(token) is { } liked
&& (await DB.Default.DeleteAsync<Favourite>(liked.ID)).DeletedCount > 0)
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(b => b.Inc(p => p.FavouritesCount, -1)).ExecuteAsync(token);
} }
} }
} }
@@ -59,6 +59,10 @@ namespace PrivaPub.Federation.Inbox.Handlers
} }
Arrival.Accept("stored"); Arrival.Accept("stored");
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(b => b.Inc(p => p.FavouritesCount, 1)).ExecuteAsync(token); await DB.Default.Update<PostEntity>().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<Downvote>().Match(d => d.PostId == post.ID && d.ActorURI == actor.ActorURI).ExecuteFirstAsync(token) is { } downvoted
&& (await DB.Default.DeleteAsync<Downvote>(downvoted.ID)).DeletedCount > 0)
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(b => b.Inc(p => p.DownvotesCount, -1)).ExecuteAsync(token);
if (!post.IsFederatedCopy) if (!post.IsFederatedCopy)
await Notifications.Add(post.GroupUserId, NotificationType.Favourite, actor.ID, actor.ActorURI, post.ID, token); await Notifications.Add(post.GroupUserId, NotificationType.Favourite, actor.ID, actor.ActorURI, post.ID, token);
} }
+6 -11
View File
@@ -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" && ok "alice's reply becomes a comment on Lemmy" || ko "PrivaPub reply missing as a Lemmy comment"
echo " votes" 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 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 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" && ok "Lemmy's upvote counts as a like on PrivaPub" || ko "Lemmy's relayed upvote not counted"
else
xf "Lemmy's upvote counts as a like on PrivaPub (votes relayed by a community are P7)"
fi
lm POST post/like "{\"post_id\":$p_thread_on_lm,\"is_upvote\":false}" >/dev/null 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 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" && ok "Lemmy's change to a downvote reaches PrivaPub" || ko "Lemmy's change to a downvote not applied"
else
xf "Lemmy's change to a downvote reaches PrivaPub (votes relayed by a community are P7)"
fi
curl -s -o /dev/null -X POST -H "$LAH" "$P/api/v1/statuses/$lm_cats_on_p/favourite" 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" 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"