Votes out. A persona's downvote is a Dislike (POST /api/v1/statuses/:id/downvote and /undownvote, the viewer's own as privapub.votes.downvoted). One vote a post: a changed vote is sent as the new vote alone, as Lemmy sends one, since an Undo of the old one beside it could arrive after it and take the new one away; Undo goes only when a vote is taken back. A vote on a post in a remote community goes to the community, which counts it, and to the author only when on another server: one copy a server, since PieFed drops a second copy of an activity it has just seen. Mbin keeps a favourite apart from a vote, so there a favourite stays after a switch to a downvote. Feeds followed (owner decision 2026-10-07: through the server). Following a feed (Lemmy's multi-community, PieFed's feed) keeps a FeedSubscription for the persona and nothing else; the new Service privapub_feeds (LocalActorKind.Reader, reserved by migration _017) follows every community of the feeds read here, reconciled when a persona follows or leaves one, when a feed's list is read again (kept as it was when it cannot be read) and every six hours. Its Following rows carry FollowerKind, so nobody's home gets what it brings in and its unanswered follows are sent again as its own. The persona reads GET /api/v1/timelines/feed/:id (the feed's threads, ours included) and lists its feeds at GET /api/v1/feeds. Checked in the pasture, every scenario: 873 pass. The 14 failures are Hubzilla's (identical on the previous commit: Hubzilla no longer answers a follow in this pasture since its restore) and two activities Smithereen never sent; followsync passes once the pasture's restore is older than the 14-day pause. Live: alice's downvotes count as downvotes on Lemmy 1.0 and PieFed, replacing her upvote; Lemmy 1.0 and PieFed take privapub_feeds' follows and their threads reach the feed timelines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
729 lines
40 KiB
C#
729 lines
40 KiB
C#
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Models.Jobs;
|
|
using PrivaPub.Models.User;
|
|
using PrivaPub.Tests.Support;
|
|
using PrivaPub.Tests.Support.Host;
|
|
|
|
using System.Net;
|
|
using System.Text.Json.Nodes;
|
|
|
|
namespace PrivaPub.Tests.Http
|
|
{
|
|
[Trait("Category", "Integration")]
|
|
public sealed class MastodonStatusesTests : IAsyncLifetime
|
|
{
|
|
PrivaPubHost _host;
|
|
Peer _peer;
|
|
|
|
public async ValueTask InitializeAsync()
|
|
{
|
|
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
|
_host = await PrivaPubHost.Shared();
|
|
_peer = await Peer.Start();
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (_peer != default)
|
|
await _peer.DisposeAsync();
|
|
}
|
|
|
|
static IEnumerable<string> Strings(JsonNode node) => node!.AsArray().Select(n => n!.GetValue<string>());
|
|
|
|
static async Task<JsonObject> Image(Mastodon account) =>
|
|
(await account.Client.Exchange(new HttpRequestMessage(HttpMethod.Post, "/api/v1/media")
|
|
{
|
|
Content = MastodonHelpers.Multipart(("file", MastodonHelpers.JpegWithMetadata(80, 60), "image/jpeg", "a.jpg"))
|
|
})).Ok().Object;
|
|
|
|
[Fact]
|
|
public async Task Each_visibility_is_addressed_the_way_mastodon_addresses_it()
|
|
{
|
|
var since = DateTime.UtcNow.AddSeconds(-1);
|
|
var alice = await _host.Mastodon("alice");
|
|
var bob = new RemoteActor(_peer, "bob");
|
|
var carol = new RemoteActor(_peer, "carol");
|
|
await _host.FollowedBy(alice, bob);
|
|
_peer.WebFinger(carol);
|
|
var followers = alice.Uri + "/groupies";
|
|
|
|
var open = await alice.Status("for everyone");
|
|
var unlisted = await alice.Status("quietly public", ("visibility", "unlisted"));
|
|
var quiet = await alice.Status("for followers", ("visibility", "private"));
|
|
var direct = await alice.Status($"@{carol.Handle()} just you", ("visibility", "direct"));
|
|
|
|
Assert.Equal(new[] { "public", "unlisted", "private", "direct" }, new[] { open, unlisted, quiet, direct }.Select(s => s.Text("visibility")));
|
|
var creates = (await bob.Delivered(since)).Where(d => d.Type() == "Create").ToDictionary(d => d["object"].Text("id"));
|
|
Assert.Equal(3, creates.Count);
|
|
var publicNote = creates[open.Text("uri")]["object"];
|
|
Assert.Equal(new[] { MastodonHelpers.Public }, Strings(publicNote["to"]));
|
|
Assert.Equal(new[] { followers }, Strings(publicNote["cc"]));
|
|
var unlistedNote = creates[unlisted.Text("uri")]["object"];
|
|
Assert.Equal(new[] { followers }, Strings(unlistedNote["to"]));
|
|
Assert.Equal(new[] { MastodonHelpers.Public }, Strings(unlistedNote["cc"]));
|
|
var quietNote = creates[quiet.Text("uri")]["object"];
|
|
Assert.Equal(new[] { followers }, Strings(quietNote["to"]));
|
|
// named again in cc: Pleroma and Akkoma read an empty cc without a "/followers" address as a direct message
|
|
Assert.Equal(new[] { followers }, Strings(quietNote["cc"]));
|
|
var whisper = Assert.Single(await carol.Delivered(since));
|
|
Assert.Equal(direct.Text("uri"), whisper["object"].Text("id"));
|
|
Assert.Equal(new[] { carol.Id }, Strings(whisper["object"]!["to"]));
|
|
Assert.Empty(whisper["object"]!["cc"]!.AsArray());
|
|
Assert.Equal(carol.Handle(), Assert.Single(direct["mentions"]!.AsArray()).Text("acct"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_content_warning_is_kept_as_spoiler_text_and_federates_as_a_summary()
|
|
{
|
|
var alice = await _host.Mastodon("alice");
|
|
|
|
var warned = await alice.Status("the butler did it", ("spoiler_text", "spoilers"));
|
|
|
|
Assert.True(warned.Flag("sensitive"));
|
|
Assert.Equal("spoilers", warned.Text("spoiler_text"));
|
|
var note = (await _host.ActivityPub($"/peasants/{alice.UserName}/scribbles/{warned.Text("id")}")).Ok().Body;
|
|
Assert.Equal("spoilers", note.Text("summary"));
|
|
Assert.True(note.Flag("sensitive"));
|
|
Assert.Contains("the butler did it", note.Text("content"));
|
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v1/statuses", ("status", " "))).Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_reply_to_a_local_post_threads_counts_and_shows_in_the_context()
|
|
{
|
|
var alice = await _host.Mastodon("alice");
|
|
var bob = await _host.Mastodon("bob");
|
|
var root = await alice.Status("what do you think?");
|
|
|
|
var reply = await bob.Status("I agree", ("in_reply_to_id", root.Text("id")));
|
|
var nested = await alice.Status("thanks", ("in_reply_to_id", reply.Text("id")));
|
|
|
|
Assert.Equal(root.Text("id"), reply.Text("in_reply_to_id"));
|
|
Assert.Equal(alice.Id, reply.Text("in_reply_to_account_id"));
|
|
Assert.Equal(1, (await bob.Client.Get($"/api/v1/statuses/{root.Text("id")}")).Ok().Body.Number("replies_count"));
|
|
var context = (await _host.Client().Get($"/api/v1/statuses/{reply.Text("id")}/context")).Ok();
|
|
Assert.Equal(new[] { root.Text("id") }, context.Body["ancestors"]!.AsArray().Select(s => s.Text("id")));
|
|
Assert.Equal(new[] { nested.Text("id") }, context.Body["descendants"]!.AsArray().Select(s => s.Text("id")));
|
|
var whole = (await alice.Client.Get($"/api/v1/statuses/{root.Text("id")}/context")).Ok();
|
|
Assert.Equal(new[] { reply.Text("id"), nested.Text("id") }, whole.Body["descendants"]!.AsArray().Select(s => s.Text("id")));
|
|
Assert.Equal(HttpStatusCode.NotFound, (await bob.Client.Post("/api/v1/statuses", ("status", "lost"), ("in_reply_to_id", "000000000000000000000000"))).Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_reply_to_a_remote_post_mentions_its_author_and_reaches_its_inbox()
|
|
{
|
|
var since = DateTime.UtcNow.AddSeconds(-1);
|
|
var alice = await _host.Mastodon("alice");
|
|
var bob = new RemoteActor(_peer, "bob");
|
|
_peer.WebFinger(bob);
|
|
var parent = await _host.PublicPostFrom(bob, alice);
|
|
var bobId = (await _host.Known(bob)).ID;
|
|
|
|
var reply = await alice.Status($"@{bob.Handle()} well said", ("in_reply_to_id", parent.ID));
|
|
|
|
Assert.Equal(parent.ID, reply.Text("in_reply_to_id"));
|
|
Assert.Equal(bobId, reply.Text("in_reply_to_account_id"));
|
|
var mention = Assert.Single(reply["mentions"]!.AsArray());
|
|
Assert.Equal((bobId, bob.Handle()), (mention.Text("id"), mention.Text("acct")));
|
|
var create = Assert.Single(await bob.Delivered(since), d => d.Type() == "Create");
|
|
var note = create["object"]!;
|
|
Assert.Equal(parent.ObjectURI, note.Text("inReplyTo"));
|
|
Assert.Contains(bob.Id, Strings(note["cc"]));
|
|
Assert.Contains(note["tag"]!.AsArray(), t => t.Text("type") == "Mention" && t.Text("href") == bob.Id);
|
|
Assert.Equal(1, (await alice.Client.Get($"/api/v1/statuses/{parent.ID}")).Ok().Body.Number("replies_count"));
|
|
}
|
|
|
|
// a remote thread opened by a persona has its replies read from its server (here its FEP-7888 context), at most hourly
|
|
[Fact]
|
|
public async Task Opening_a_remote_thread_reads_the_replies_that_never_reached_us()
|
|
{
|
|
var token = TestContext.Current.CancellationToken;
|
|
var alice = await _host.Mastodon("alice");
|
|
var bob = new RemoteActor(_peer, "bob");
|
|
var carol = new RemoteActor(_peer, "carol");
|
|
var context = $"{bob.Origin()}/contexts/{Guid.NewGuid():N}";
|
|
var post = await _host.PublicPostFrom(bob, alice, shape: note => note["context"] = context);
|
|
var answer = carol.Create("<p>said elsewhere</p>", new[] { MastodonHelpers.Public }, new[] { carol.Id + "/followers" },
|
|
note => note["inReplyTo"] = post.ObjectURI)["object"]!;
|
|
var answerId = answer["id"]!.GetValue<string>();
|
|
_peer.Serve(new Uri(answerId).AbsolutePath, answer.ToJsonString());
|
|
_peer.Serve(new Uri(context).AbsolutePath, new JsonObject
|
|
{
|
|
["id"] = context, ["type"] = "OrderedCollection", ["orderedItems"] = new JsonArray(post.ObjectURI, answerId)
|
|
}.ToJsonString());
|
|
async Task<int> Queued() => (await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.FetchReplies && j.Payload.Contains(post.ID)).ExecuteAsync(token)).Count;
|
|
|
|
(await _host.Client().Get($"/api/v1/statuses/{post.ID}/context")).Ok();
|
|
Assert.Equal(0, await Queued());
|
|
Assert.Empty((await alice.Client.Get($"/api/v1/statuses/{post.ID}/context")).Ok().Body["descendants"]!.AsArray());
|
|
Assert.Equal(1, await _host.Run(j => j.Kind == JobKind.FetchReplies && j.Payload.Contains(post.ID), token));
|
|
|
|
var descendants = (await alice.Client.Get($"/api/v1/statuses/{post.ID}/context")).Ok().Body["descendants"]!.AsArray();
|
|
Assert.Equal(answerId, Assert.Single(descendants).Text("uri"));
|
|
Assert.Equal(1, await Queued());
|
|
}
|
|
|
|
// Akkoma marks a post with sensitive media "sensitive" and leaves its summary empty: the media is hidden, the words are
|
|
// not, and no warning is made up for them (found by decePub's e2e tests on the town)
|
|
[Fact]
|
|
public async Task A_remote_post_sensitive_without_a_summary_hides_its_media_and_not_its_words()
|
|
{
|
|
var alice = await _host.Mastodon("alice");
|
|
var bob = new RemoteActor(_peer, "bob");
|
|
_peer.WebFinger(bob);
|
|
var post = await _host.PublicPostFrom(bob, alice, shape: note => { note["sensitive"] = true; note["summary"] = ""; });
|
|
|
|
var status = (await alice.Client.Get($"/api/v1/statuses/{post.ID}")).Ok().Body;
|
|
|
|
Assert.True(status["sensitive"]!.GetValue<bool>());
|
|
Assert.Equal("", status.Text("spoiler_text"));
|
|
}
|
|
|
|
// a status asked for by id is shown even when the viewer blocked or muted its author, as Mastodon shows it: only lists
|
|
// leave such posts out (it once answered 200 with null, and decePub's thread page showed nothing)
|
|
[Fact]
|
|
public async Task A_status_of_an_author_the_viewer_blocked_is_still_shown_by_its_id()
|
|
{
|
|
var alice = await _host.Mastodon("alice");
|
|
var bob = new RemoteActor(_peer, "bob");
|
|
_peer.WebFinger(bob);
|
|
var post = await _host.PublicPostFrom(bob, alice);
|
|
var bobId = (await _host.Known(bob)).ID;
|
|
(await alice.Client.Post($"/api/v1/accounts/{bobId}/block")).Ok();
|
|
|
|
var status = (await alice.Client.Get($"/api/v1/statuses/{post.ID}")).Ok().Body;
|
|
|
|
Assert.Equal(post.ID, status.Text("id"));
|
|
}
|
|
|
|
// a reader who deletes the @name decePub prefills still answers its author: GoToSocial dropped such a reply, addressed
|
|
// to nobody there (found by decePub's e2e tests on the town)
|
|
[Fact]
|
|
public async Task A_reply_without_the_authors_name_still_names_the_author_in_cc()
|
|
{
|
|
var since = DateTime.UtcNow.AddSeconds(-1);
|
|
var alice = await _host.Mastodon("alice");
|
|
var bob = new RemoteActor(_peer, "bob");
|
|
_peer.WebFinger(bob);
|
|
var parent = await _host.PublicPostFrom(bob, alice);
|
|
|
|
var reply = await alice.Status("no name in this one", ("in_reply_to_id", parent.ID));
|
|
|
|
Assert.Empty(reply["mentions"]!.AsArray());
|
|
var create = Assert.Single(await bob.Delivered(since), d => d.Type() == "Create");
|
|
Assert.Contains(bob.Id, Strings(create["object"]!["cc"]));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_poll_is_created_validated_and_federated_as_a_question()
|
|
{
|
|
var alice = await _host.Mastodon("alice");
|
|
|
|
var status = await alice.Status("tea or coffee?", ("poll[options][]", "tea"), ("poll[options][]", "coffee"), ("poll[expires_in]", "600"),
|
|
("poll[multiple]", "true"));
|
|
|
|
var poll = status["poll"]!;
|
|
Assert.Equal(new[] { "tea", "coffee" }, poll["options"]!.AsArray().Select(o => o.Text("title")));
|
|
Assert.True(poll.Flag("multiple"));
|
|
Assert.False(poll.Flag("expired"));
|
|
Assert.NotNull(poll.Text("expires_at"));
|
|
Assert.Equal(status.Text("id"), (await _host.Client().Get($"/api/v1/polls/{status.Text("id")}")).Ok().Body.Text("id"));
|
|
var question = (await _host.ActivityPub($"/peasants/{alice.UserName}/scribbles/{status.Text("id")}")).Ok().Body;
|
|
Assert.Equal("Question", question.Text("type"));
|
|
Assert.Equal(2, question["anyOf"]!.AsArray().Count);
|
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v1/statuses", ("status", "one?"), ("poll[options][]", "only"),
|
|
("poll[expires_in]", "600"))).Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Media_is_attached_only_by_its_owner()
|
|
{
|
|
var alice = await _host.Mastodon("alice");
|
|
var mallory = await _host.Mastodon("mallory");
|
|
var upload = await Image(alice);
|
|
|
|
var status = await alice.Status("look", ("media_ids[]", upload.Text("id")));
|
|
|
|
var attachment = Assert.Single(status["media_attachments"]!.AsArray());
|
|
Assert.Equal(upload.Text("id"), attachment.Text("id"));
|
|
Assert.Equal("image", attachment.Text("type"));
|
|
var note = (await _host.ActivityPub($"/peasants/{alice.UserName}/scribbles/{status.Text("id")}")).Ok().Body;
|
|
Assert.Equal(upload.Text("url"), Assert.Single(note["attachment"]!.AsArray()).Text("url"));
|
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await mallory.Client.Post("/api/v1/statuses", ("status", "mine"), ("media_ids[]", (await Image(alice)).Text("id")))).Status);
|
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v1/statuses", ("status", "again"), ("media_ids[]", upload.Text("id")))).Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_quote_of_a_local_post_is_accepted_listed_and_revocable()
|
|
{
|
|
var alice = await _host.Mastodon("alice");
|
|
var carol = await _host.Mastodon("carol");
|
|
var original = await carol.Status("quote me");
|
|
var closed = await carol.Status("do not quote me", ("quote_approval_policy", "nobody"));
|
|
|
|
var quote = await alice.Status("so true", ("quoted_status_id", original.Text("id")));
|
|
|
|
Assert.Equal("accepted", quote["quote"].Text("state"));
|
|
Assert.Equal(original.Text("id"), quote["quote"]!["quoted_status"].Text("id"));
|
|
Assert.Equal(1, (await carol.Client.Get($"/api/v1/statuses/{original.Text("id")}")).Ok().Body.Number("quotes_count"));
|
|
Assert.Equal(new[] { quote.Text("id") }, (await _host.Client().Get($"/api/v1/statuses/{original.Text("id")}/quotes")).Ok().Ids);
|
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v1/statuses", ("status", "anyway"), ("quoted_status_id", closed.Text("id")))).Status);
|
|
Assert.Empty(closed["quote_approval"]!["automatic"]!.AsArray());
|
|
|
|
Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Post($"/api/v1/statuses/{original.Text("id")}/quotes/{quote.Text("id")}/revoke")).Status);
|
|
var revoked = (await carol.Client.Post($"/api/v1/statuses/{original.Text("id")}/quotes/{quote.Text("id")}/revoke")).Ok();
|
|
|
|
Assert.Equal(quote.Text("id"), revoked.Body.Text("id"));
|
|
Assert.Equal("revoked", revoked.Body["quote"].Text("state"));
|
|
Assert.Empty((await carol.Client.Get($"/api/v1/statuses/{original.Text("id")}/quotes")).Ok().Array);
|
|
Assert.Equal(0, (await carol.Client.Get($"/api/v1/statuses/{original.Text("id")}")).Ok().Body.Number("quotes_count"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_edit_keeps_its_history_shows_its_source_and_federates_an_update()
|
|
{
|
|
var since = DateTime.UtcNow.AddSeconds(-1);
|
|
var alice = await _host.Mastodon("alice");
|
|
var other = await _host.Mastodon("other");
|
|
var bob = new RemoteActor(_peer, "bob");
|
|
await _host.FollowedBy(alice, bob);
|
|
var status = await alice.Status("first version", ("spoiler_text", "cw"));
|
|
var id = status.Text("id");
|
|
|
|
var edited = (await alice.Client.Put($"/api/v1/statuses/{id}", ("status", "second version"))).Ok();
|
|
|
|
Assert.NotNull(edited.Body.Text("edited_at"));
|
|
Assert.Contains("second version", edited.Body.Text("content"));
|
|
Assert.Contains("second version", (await other.Client.Get($"/api/v1/statuses/{id}")).Ok().Body.Text("content"));
|
|
var history = (await _host.Client().Get($"/api/v1/statuses/{id}/history")).Ok().Array;
|
|
Assert.Equal(2, history.Count);
|
|
Assert.Contains("first version", history[0].Text("content"));
|
|
Assert.Equal("cw", history[0].Text("spoiler_text"));
|
|
Assert.Contains("second version", history[1].Text("content"));
|
|
Assert.Equal(alice.Id, history[1]!["account"].Text("id"));
|
|
var source = (await alice.Client.Get($"/api/v1/statuses/{id}/source")).Ok();
|
|
Assert.Equal(("second version", ""), (source.Body.Text("text"), source.Body.Text("spoiler_text")));
|
|
Assert.Equal(HttpStatusCode.NotFound, (await other.Client.Get($"/api/v1/statuses/{id}/source")).Status);
|
|
Assert.Equal(HttpStatusCode.NotFound, (await other.Client.Put($"/api/v1/statuses/{id}", ("status", "hijacked"))).Status);
|
|
var update = Assert.Single(await bob.Delivered(since), d => d.Type() == "Update");
|
|
Assert.Equal(status.Text("uri"), update["object"].Text("id"));
|
|
Assert.Contains("second version", update["object"].Text("content"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Delete_returns_the_text_for_a_redraft_and_leaves_a_tombstone()
|
|
{
|
|
var since = DateTime.UtcNow.AddSeconds(-1);
|
|
var alice = await _host.Mastodon("alice");
|
|
var other = await _host.Mastodon("other");
|
|
var bob = new RemoteActor(_peer, "bob");
|
|
await _host.FollowedBy(alice, bob);
|
|
var status = await alice.Status("regrettable", ("spoiler_text", "hot take"));
|
|
var id = status.Text("id");
|
|
var scribble = $"/peasants/{alice.UserName}/scribbles/{id}";
|
|
Assert.Equal(HttpStatusCode.OK, (await _host.ActivityPub(scribble)).Status);
|
|
Assert.Equal(HttpStatusCode.NotFound, (await other.Client.Delete($"/api/v1/statuses/{id}")).Status);
|
|
|
|
var deleted = (await alice.Client.Delete($"/api/v1/statuses/{id}")).Ok();
|
|
|
|
Assert.Equal(id, deleted.Body.Text("id"));
|
|
Assert.Equal("regrettable", deleted.Body.Text("text"));
|
|
Assert.Equal("hot take", deleted.Body.Text("spoiler_text"));
|
|
Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Get($"/api/v1/statuses/{id}")).Status);
|
|
Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Delete($"/api/v1/statuses/{id}")).Status);
|
|
var tombstone = await _host.ActivityPub(scribble);
|
|
Assert.Equal(HttpStatusCode.Gone, tombstone.Status);
|
|
Assert.Equal("Tombstone", tombstone.Body.Text("type"));
|
|
Assert.Equal(status.Text("uri"), tombstone.Body.Text("id"));
|
|
var delete = Assert.Single(await bob.Delivered(since), d => d.Type() == "Delete");
|
|
Assert.Equal(status.Text("uri"), delete["object"].Text("id"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Favourites_and_reblogs_count_and_federate_with_their_undos()
|
|
{
|
|
var since = DateTime.UtcNow.AddSeconds(-1);
|
|
var alice = await _host.Mastodon("alice");
|
|
var carol = await _host.Mastodon("carol");
|
|
var bob = new RemoteActor(_peer, "bob");
|
|
var remote = await _host.PublicPostFrom(bob, alice);
|
|
var path = $"/api/v1/statuses/{remote.ID}";
|
|
|
|
var liked = (await alice.Client.Post(path + "/favourite")).Ok();
|
|
Assert.True(liked.Body.Flag("favourited"));
|
|
Assert.Equal(1, liked.Body.Number("favourites_count"));
|
|
Assert.Equal(new[] { alice.Id }, (await _host.Client().Get(path + "/favourited_by")).Ok().Ids);
|
|
Assert.Equal(1, (await alice.Client.Post(path + "/favourite")).Ok().Body.Number("favourites_count"));
|
|
var unliked = (await alice.Client.Post(path + "/unfavourite")).Ok();
|
|
Assert.False(unliked.Body.Flag("favourited"));
|
|
Assert.Equal(0, unliked.Body.Number("favourites_count"));
|
|
|
|
var reblog = (await alice.Client.Post(path + "/reblog")).Ok();
|
|
Assert.Equal(remote.ID, reblog.Body["reblog"].Text("id"));
|
|
Assert.True(reblog.Body["reblog"].Flag("reblogged"));
|
|
Assert.Equal(1, reblog.Body["reblog"].Number("reblogs_count"));
|
|
Assert.Equal(new[] { alice.Id }, (await carol.Client.Get(path + "/reblogged_by")).Ok().Ids);
|
|
var unreblogged = (await alice.Client.Post(path + "/unreblog")).Ok();
|
|
Assert.False(unreblogged.Body.Flag("reblogged"));
|
|
Assert.Equal(0, unreblogged.Body.Number("reblogs_count"));
|
|
Assert.Empty((await carol.Client.Get(path + "/reblogged_by")).Ok().Array);
|
|
|
|
var sent = await bob.Delivered(since);
|
|
var like = Assert.Single(sent, d => d.Type() == "Like");
|
|
Assert.Equal((alice.Uri, remote.ObjectURI), (like.Text("actor"), like.Text("object")));
|
|
var announce = Assert.Single(sent, d => d.Type() == "Announce");
|
|
Assert.Equal(remote.ObjectURI, announce.Text("object"));
|
|
var undos = sent.Where(d => d.Type() == "Undo").ToList();
|
|
Assert.Equal(new[] { "Announce", "Like" }, undos.Select(u => u["object"].Text("type")).Order());
|
|
Assert.Equal(like.Text("id"), undos.Single(u => u["object"].Text("type") == "Like")["object"].Text("id"));
|
|
|
|
var local = await carol.Status("a local one");
|
|
(await alice.Client.Post($"/api/v1/statuses/{local.Text("id")}/favourite")).Ok();
|
|
var notification = Assert.Single((await carol.Client.Get("/api/v1/notifications?types[]=favourite")).Ok().Array);
|
|
Assert.Equal((alice.Id, local.Text("id")), (notification["account"].Text("id"), notification["status"].Text("id")));
|
|
var quiet = await carol.Status("followers only", ("visibility", "private"));
|
|
(await alice.Client.Post($"/api/v1/accounts/{carol.Id}/follow")).Ok();
|
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post($"/api/v1/statuses/{quiet.Text("id")}/reblog")).Status);
|
|
Assert.True((await alice.Client.Post($"/api/v1/statuses/{quiet.Text("id")}/favourite")).Ok().Body.Flag("favourited"));
|
|
}
|
|
|
|
// a downvote is a Dislike, one vote a post: a downvote replaces the favourite and a favourite the downvote, each by its
|
|
// own activity alone; a vote on a post in a community goes to the community, which counts it, and to its author only
|
|
// when on another server
|
|
[Fact]
|
|
public async Task A_downvote_replaces_the_favourite_and_votes_reach_the_posts_community()
|
|
{
|
|
var since = DateTime.UtcNow.AddSeconds(-1);
|
|
var alice = await _host.Mastodon("alice");
|
|
var bob = new RemoteActor(_peer, "bob", origin: _peer.B);
|
|
var community = new RemoteActor(_peer, "pies", type: "Group");
|
|
await _host.Get<PrivaPub.Federation.Actors.IRemoteActorService>().GetActor(community.Id, refresh: true, TestContext.Current.CancellationToken);
|
|
var remote = await _host.PublicPostFrom(bob, alice, shape: note => note["audience"] = community.Id);
|
|
var neighbour = new RemoteActor(_peer, "neighbour");
|
|
var nearby = await _host.PublicPostFrom(neighbour, alice, shape: note => note["audience"] = community.Id);
|
|
(await alice.Client.Post($"/api/v1/statuses/{nearby.ID}/favourite")).Ok();
|
|
Assert.Empty(await neighbour.Delivered(since));
|
|
var path = $"/api/v1/statuses/{remote.ID}";
|
|
|
|
(await alice.Client.Post(path + "/favourite")).Ok();
|
|
var down = (await alice.Client.Post(path + "/downvote")).Ok().Body;
|
|
Assert.False(down.Flag("favourited"));
|
|
Assert.Equal((0, 1, true), (down["privapub"]!["votes"].Number("up"), down["privapub"]!["votes"].Number("down"), down["privapub"]!["votes"].Flag("downvoted")));
|
|
Assert.Equal(1, (await alice.Client.Post(path + "/downvote")).Ok().Body["privapub"]!["votes"].Number("down"));
|
|
var up = (await alice.Client.Post(path + "/favourite")).Ok().Body;
|
|
Assert.Equal((true, 0, false), (up.Flag("favourited"), up["privapub"]!["votes"].Number("down"), up["privapub"]!["votes"].Flag("downvoted")));
|
|
Assert.Null((await _host.Client().Get(path)).Ok().Body["privapub"]!["votes"]!["downvoted"]);
|
|
(await alice.Client.Post(path + "/downvote")).Ok();
|
|
var none = (await alice.Client.Post(path + "/undownvote")).Ok().Body;
|
|
Assert.Equal((false, 0, 0), (none.Flag("favourited"), none.Number("favourites_count"), none["privapub"]!["votes"].Number("down")));
|
|
|
|
foreach (var inbox in new[] { bob, community })
|
|
{
|
|
var sent = (await inbox.Delivered(since)).Where(d => (d.Type() == "Undo" ? d["object"].Text("object") : d.Text("object")) == remote.ObjectURI).ToList();
|
|
Assert.Equal(new[] { "Like", "Dislike", "Like", "Dislike", "Undo:Dislike" },
|
|
sent.Select(d => d.Type() == "Undo" ? "Undo:" + d["object"].Text("type") : d.Type()));
|
|
var dislike = sent.Last(d => d.Type() == "Dislike");
|
|
Assert.Equal((alice.Uri, remote.ObjectURI), (dislike.Text("actor"), dislike.Text("object")));
|
|
Assert.Equal(dislike.Text("id"), sent.Single(d => d.Type() == "Undo")["object"].Text("id"));
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_favourite_or_a_boost_given_again_after_its_undo_is_a_new_activity_that_is_delivered()
|
|
{
|
|
var since = DateTime.UtcNow.AddSeconds(-1);
|
|
var alice = await _host.Mastodon("alice");
|
|
var bob = new RemoteActor(_peer, "bob");
|
|
var remote = await _host.PublicPostFrom(bob, alice);
|
|
var path = $"/api/v1/statuses/{remote.ID}";
|
|
|
|
foreach (var (on, off) in new[] { ("favourite", "unfavourite"), ("reblog", "unreblog") })
|
|
{
|
|
(await alice.Client.Post($"{path}/{on}")).Ok();
|
|
(await alice.Client.Post($"{path}/{off}")).Ok();
|
|
(await alice.Client.Post($"{path}/{on}")).Ok();
|
|
}
|
|
|
|
var sent = await bob.Delivered(since);
|
|
foreach (var type in new[] { "Like", "Announce" })
|
|
{
|
|
var given = sent.Where(d => d.Type() == type).ToList();
|
|
Assert.Equal(2, given.Count);
|
|
Assert.NotEqual(given[0].Text("id"), given[1].Text("id"));
|
|
var undo = Assert.Single(sent, d => d.Type() == "Undo" && d["object"].Text("type") == type);
|
|
Assert.Equal(given[0].Text("id"), undo["object"].Text("id"));
|
|
}
|
|
var status = (await alice.Client.Get(path)).Ok().Body;
|
|
Assert.True(status.Flag("favourited"));
|
|
Assert.True(status.Flag("reblogged"));
|
|
Assert.Equal(1, status.Number("reblogs_count"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_boost_reaches_the_authors_server_once_through_its_shared_inbox_and_a_like_its_own_inbox()
|
|
{
|
|
var since = DateTime.UtcNow.AddSeconds(-1);
|
|
var alice = await _host.Mastodon("alice");
|
|
var bob = new RemoteActor(_peer, "bob");
|
|
var remote = await _host.PublicPostFrom(bob, alice);
|
|
await DB.Default.Update<ForeignAvatar>().Match(f => f.ActorURI == bob.Id).Modify(f => f.SharedInboxURL, bob.SharedInbox)
|
|
.ExecuteAsync(TestContext.Current.CancellationToken);
|
|
|
|
(await alice.Client.Post($"/api/v1/statuses/{remote.ID}/reblog")).Ok();
|
|
(await alice.Client.Post($"/api/v1/statuses/{remote.ID}/unreblog")).Ok();
|
|
(await alice.Client.Post($"/api/v1/statuses/{remote.ID}/favourite")).Ok();
|
|
|
|
var shared = await Jobs.Deliveries(bob.SharedInbox, since, TestContext.Current.CancellationToken);
|
|
Assert.Single(shared, d => d.Type() == "Announce");
|
|
Assert.Single(shared, d => d.Type() == "Undo");
|
|
var own = await bob.Delivered(since);
|
|
Assert.DoesNotContain(own, d => d.Type() is "Announce" or "Undo");
|
|
Assert.Single(own, d => d.Type() == "Like");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Bookmarks_are_kept_listed_and_removed()
|
|
{
|
|
var alice = await _host.Mastodon("alice");
|
|
var carol = await _host.Mastodon("carol");
|
|
var first = await carol.Status("worth keeping");
|
|
var second = await carol.Status("this too");
|
|
|
|
Assert.True((await alice.Client.Post($"/api/v1/statuses/{first.Text("id")}/bookmark")).Ok().Body.Flag("bookmarked"));
|
|
(await alice.Client.Post($"/api/v1/statuses/{second.Text("id")}/bookmark")).Ok();
|
|
(await alice.Client.Post($"/api/v1/statuses/{second.Text("id")}/bookmark")).Ok();
|
|
|
|
Assert.Equal(new[] { second.Text("id"), first.Text("id") }, (await alice.Client.Get("/api/v1/bookmarks")).Ok().Ids);
|
|
Assert.Empty((await carol.Client.Get("/api/v1/bookmarks")).Ok().Array);
|
|
var page = (await alice.Client.Get("/api/v1/bookmarks?limit=1")).Ok();
|
|
Assert.Equal(new[] { second.Text("id") }, page.Ids);
|
|
Assert.Equal(new[] { first.Text("id") }, (await alice.Client.Get(page.Link("next"))).Ok().Ids);
|
|
Assert.False((await alice.Client.Post($"/api/v1/statuses/{first.Text("id")}/unbookmark")).Ok().Body.Flag("bookmarked"));
|
|
Assert.Equal(new[] { second.Text("id") }, (await alice.Client.Get("/api/v1/bookmarks")).Ok().Ids);
|
|
Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Post("/api/v1/statuses/000000000000000000000000/bookmark")).Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Pins_take_only_the_owners_public_posts()
|
|
{
|
|
var alice = await _host.Mastodon("alice");
|
|
var carol = await _host.Mastodon("carol");
|
|
var mine = await alice.Status("pin me");
|
|
var quiet = await alice.Status("not this", ("visibility", "private"));
|
|
var theirs = await carol.Status("not yours");
|
|
|
|
Assert.True((await alice.Client.Post($"/api/v1/statuses/{mine.Text("id")}/pin")).Ok().Body.Flag("pinned"));
|
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post($"/api/v1/statuses/{theirs.Text("id")}/pin")).Status);
|
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post($"/api/v1/statuses/{quiet.Text("id")}/pin")).Status);
|
|
Assert.Equal(HttpStatusCode.NotFound, (await carol.Client.Post($"/api/v1/statuses/{quiet.Text("id")}/pin")).Status);
|
|
var featured = (await _host.ActivityPub($"/peasants/{alice.UserName}/trophies")).Ok().Body;
|
|
Assert.Equal(mine.Text("uri"), Assert.Single(featured["orderedItems"]!.AsArray()).Text("id"));
|
|
|
|
for (var i = 0; i < 4; i++)
|
|
(await alice.Client.Post($"/api/v1/statuses/{(await alice.Status($"more {i}")).Text("id")}/pin")).Ok();
|
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post($"/api/v1/statuses/{(await alice.Status("one too many")).Text("id")}/pin")).Status);
|
|
|
|
Assert.False((await alice.Client.Post($"/api/v1/statuses/{mine.Text("id")}/unpin")).Ok().Body.Flag("pinned"));
|
|
Assert.Equal(4, (await alice.Client.Get($"/api/v1/accounts/{alice.Id}/statuses?pinned=true")).Ok().Array.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task The_interaction_policy_sets_who_may_quote_and_matches_the_activitypub_note()
|
|
{
|
|
var since = DateTime.UtcNow.AddSeconds(-1);
|
|
var alice = await _host.Mastodon("alice");
|
|
var bob = new RemoteActor(_peer, "bob");
|
|
await _host.FollowedBy(alice, bob);
|
|
var open = await alice.Status("quotable");
|
|
var quiet = await alice.Status("followers only", ("visibility", "private"));
|
|
var scribble = $"/peasants/{alice.UserName}/scribbles/{open.Text("id")}";
|
|
string CanQuote(JsonNode note) => Assert.Single(note["interactionPolicy"]!["canQuote"]!["automaticApproval"]!.AsArray())!.GetValue<string>();
|
|
|
|
Assert.Equal(new[] { "public" }, Strings(open["quote_approval"]!["automatic"]));
|
|
Assert.Equal(MastodonHelpers.Public, CanQuote((await _host.ActivityPub(scribble)).Ok().Body));
|
|
|
|
var followers = (await alice.Client.Put($"/api/v1/statuses/{open.Text("id")}/interaction_policy", ("quote_approval_policy", "followers"))).Ok();
|
|
Assert.Equal(new[] { "followers" }, Strings(followers.Body["quote_approval"]!["automatic"]));
|
|
Assert.Equal(alice.Uri + "/groupies", CanQuote((await _host.ActivityPub(scribble)).Ok().Body));
|
|
|
|
var nobody = (await alice.Client.Put($"/api/v1/statuses/{open.Text("id")}/interaction_policy", ("quote_approval_policy", "nobody"))).Ok();
|
|
Assert.Empty(nobody.Body["quote_approval"]!["automatic"]!.AsArray());
|
|
Assert.Equal(alice.Uri, CanQuote((await _host.ActivityPub(scribble)).Ok().Body));
|
|
|
|
var forced = (await alice.Client.Put($"/api/v1/statuses/{quiet.Text("id")}/interaction_policy", ("quote_approval_policy", "public"))).Ok();
|
|
Assert.Empty(forced.Body["quote_approval"]!["automatic"]!.AsArray());
|
|
var updates = (await bob.Delivered(since)).Where(d => d.Type() == "Update").ToList();
|
|
Assert.Equal(3, updates.Count);
|
|
Assert.Equal(alice.Uri, CanQuote(updates.Single(u => u["object"].Text("id") == quiet.Text("uri"))["object"]));
|
|
Assert.Equal(HttpStatusCode.UnprocessableEntity,
|
|
(await alice.Client.Put($"/api/v1/statuses/{open.Text("id")}/interaction_policy", ("quote_approval_policy", "everyone"))).Status);
|
|
Assert.Equal(HttpStatusCode.NotFound,
|
|
(await (await _host.Mastodon("other")).Client.Put($"/api/v1/statuses/{open.Text("id")}/interaction_policy", ("quote_approval_policy", "public"))).Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_stranger_gets_404_for_followers_only_and_direct_posts()
|
|
{
|
|
var alice = await _host.Mastodon("alice");
|
|
var follower = await _host.Mastodon("follower");
|
|
var friend = await _host.Mastodon("friend");
|
|
var stranger = await _host.Mastodon("stranger");
|
|
(await follower.Client.Post($"/api/v1/accounts/{alice.Id}/follow")).Ok();
|
|
var quiet = await alice.Status("followers only", ("visibility", "private"));
|
|
var direct = await alice.Status($"@{friend.UserName} only you", ("visibility", "direct"));
|
|
var anonymous = _host.Client();
|
|
|
|
foreach (var id in new[] { quiet.Text("id"), direct.Text("id") })
|
|
{
|
|
Assert.Equal(HttpStatusCode.NotFound, (await stranger.Client.Get($"/api/v1/statuses/{id}")).Status);
|
|
Assert.Equal(HttpStatusCode.NotFound, (await stranger.Client.Get($"/api/v1/statuses/{id}/context")).Status);
|
|
Assert.Equal(HttpStatusCode.NotFound, (await stranger.Client.Post($"/api/v1/statuses/{id}/favourite")).Status);
|
|
Assert.Equal(HttpStatusCode.NotFound, (await stranger.Client.Post($"/api/v1/statuses/{id}/bookmark")).Status);
|
|
Assert.Equal(HttpStatusCode.NotFound, (await stranger.Client.Post("/api/v1/statuses", ("status", "re"), ("in_reply_to_id", id))).Status);
|
|
Assert.Equal(HttpStatusCode.NotFound, (await anonymous.Get($"/api/v1/statuses/{id}")).Status);
|
|
Assert.Equal(HttpStatusCode.NotFound, (await anonymous.Get($"/api/v1/statuses/{id}/favourited_by")).Status);
|
|
}
|
|
Assert.Empty((await stranger.Client.Get($"/api/v1/statuses?id[]={quiet.Text("id")}&id[]={direct.Text("id")}")).Ok().Array);
|
|
Assert.Equal(new[] { quiet.Text("id") }, (await follower.Client.Get($"/api/v1/statuses?id[]={quiet.Text("id")}&id[]={direct.Text("id")}")).Ok().Ids);
|
|
Assert.Equal(new[] { direct.Text("id") }, (await friend.Client.Get($"/api/v1/statuses?id[]={quiet.Text("id")}&id[]={direct.Text("id")}")).Ok().Ids);
|
|
Assert.Equal(HttpStatusCode.OK, (await follower.Client.Get($"/api/v1/statuses/{quiet.Text("id")}/context")).Status);
|
|
Assert.Equal(quiet.Text("id"), (await follower.Client.Post("/api/v1/statuses", ("status", "re"), ("in_reply_to_id", quiet.Text("id")))).Ok().Body.Text("in_reply_to_id"));
|
|
Assert.Equal(HttpStatusCode.NotFound, (await _host.ActivityPub($"/peasants/{alice.UserName}/scribbles/{quiet.Text("id")}")).Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_located_post_is_never_reachable_by_id_by_anyone_else()
|
|
{
|
|
var alice = await _host.Mastodon("alice");
|
|
var other = await _host.Mastodon("other");
|
|
var id = await _host.Located(alice, "the bench by the river");
|
|
var anonymous = _host.Client();
|
|
|
|
foreach (var client in new[] { other.Client, anonymous })
|
|
{
|
|
foreach (var path in new[] { "", "/context", "/history", "/favourited_by", "/reblogged_by", "/quotes" })
|
|
Assert.Equal(HttpStatusCode.NotFound, (await client.Get($"/api/v1/statuses/{id}{path}")).Status);
|
|
Assert.Equal(HttpStatusCode.NotFound, (await client.Get($"/api/privapub/v1/statuses/{id}/provenance")).Status);
|
|
Assert.Equal(HttpStatusCode.NotFound, (await client.Get($"/api/v1/pleroma/statuses/{id}/reactions")).Status);
|
|
Assert.Equal(HttpStatusCode.NotFound, (await client.Get($"/api/v1/polls/{id}")).Status);
|
|
Assert.Empty((await client.Get($"/api/v1/statuses?id[]={id}")).Ok().Array);
|
|
Assert.DoesNotContain(id, (await client.Get($"/api/v1/accounts/{alice.Id}/statuses")).Ok().Ids);
|
|
}
|
|
foreach (var action in new[] { "favourite", "reblog", "bookmark", "pin" })
|
|
Assert.Equal(HttpStatusCode.NotFound, (await other.Client.Post($"/api/v1/statuses/{id}/{action}")).Status);
|
|
Assert.Equal(HttpStatusCode.NotFound, (await other.Client.Put($"/api/v1/pleroma/statuses/{id}/reactions/%F0%9F%91%8D")).Status);
|
|
Assert.Equal(HttpStatusCode.NotFound, (await other.Client.Post("/api/v1/statuses", ("status", "re"), ("in_reply_to_id", id))).Status);
|
|
Assert.Equal(HttpStatusCode.NotFound, (await other.Client.Post("/api/v1/statuses", ("status", "qt"), ("quoted_status_id", id))).Status);
|
|
Assert.Equal(HttpStatusCode.NotFound, (await _host.ActivityPub($"/peasants/{alice.UserName}/scribbles/{id}")).Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Creating_honours_the_idempotency_key_the_scopes_and_needs_a_recipient_for_a_direct_message()
|
|
{
|
|
var alice = await _host.Mastodon("alice");
|
|
var reader = await _host.Mastodon("reader", scopes: "read");
|
|
var key = Guid.NewGuid().ToString("N");
|
|
Task<ApiAnswer> Keyed(string text)
|
|
{
|
|
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/statuses") { Content = new FormUrlEncodedContent(new Dictionary<string, string> { ["status"] = text }) };
|
|
request.Headers.Add("Idempotency-Key", key);
|
|
return alice.Client.Exchange(request);
|
|
}
|
|
|
|
var first = (await Keyed("once")).Ok();
|
|
var second = (await Keyed("once again")).Ok();
|
|
|
|
Assert.Equal(first.Body.Text("id"), second.Body.Text("id"));
|
|
Assert.Single((await alice.Client.Get($"/api/v1/accounts/{alice.Id}/statuses")).Ok().Array);
|
|
var noRecipient = await alice.Client.Post("/api/v1/statuses", ("status", "to nobody"), ("visibility", "direct"));
|
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, noRecipient.Status);
|
|
Assert.Contains("recipient", noRecipient.Body.Text("error"));
|
|
Assert.Equal(HttpStatusCode.Forbidden, (await reader.Client.Post("/api/v1/statuses", ("status", "read only"))).Status);
|
|
Assert.Equal(HttpStatusCode.Forbidden, (await reader.Client.Post($"/api/v1/statuses/{first.Body.Text("id")}/favourite")).Status);
|
|
Assert.Equal(HttpStatusCode.OK, (await reader.Client.Get($"/api/v1/statuses/{first.Body.Text("id")}")).Status);
|
|
Assert.Equal(HttpStatusCode.Unauthorized, (await _host.Client().Post("/api/v1/statuses", ("status", "anonymous"))).Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Local_polls_take_one_vote_per_persona_and_never_from_their_author()
|
|
{
|
|
var alice = await _host.Mastodon("alice");
|
|
var carol = await _host.Mastodon("carol");
|
|
var poll = await alice.Status("pick one", ("poll[options][]", "red"), ("poll[options][]", "blue"), ("poll[expires_in]", "3600"));
|
|
var path = $"/api/v1/polls/{poll.Text("id")}";
|
|
|
|
var voted = (await carol.Client.Post(path + "/votes", ("choices[]", "1"))).Ok();
|
|
|
|
Assert.True(voted.Body.Flag("voted"));
|
|
Assert.Equal(new[] { 1 }, voted.Body["own_votes"]!.AsArray().Select(v => v!.GetValue<int>()));
|
|
Assert.Equal(1, voted.Body.Number("votes_count"));
|
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await carol.Client.Post(path + "/votes", ("choices[]", "0"))).Status);
|
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post(path + "/votes", ("choices[]", "0"))).Status);
|
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await (await _host.Mastodon("dave")).Client.Post(path + "/votes", ("choices[]", "0"), ("choices[]", "1"))).Status);
|
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await (await _host.Mastodon("erin")).Client.Post(path + "/votes", ("choices[]", "7"))).Status);
|
|
var anonymous = (await _host.Client().Get(path)).Ok();
|
|
Assert.Null(anonymous.Body["voted"]);
|
|
Assert.Equal(new[] { 0, 1 }, anonymous.Body["options"]!.AsArray().Select(o => o.Number("votes_count")));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_vote_on_a_remote_poll_goes_only_to_its_author_without_a_published_date()
|
|
{
|
|
var since = DateTime.UtcNow.AddSeconds(-1);
|
|
var alice = await _host.Mastodon("alice");
|
|
var bob = new RemoteActor(_peer, "bob");
|
|
var carol = new RemoteActor(_peer, "carol");
|
|
await _host.FollowedBy(alice, carol);
|
|
var question = await _host.PublicPostFrom(bob, alice, "<p>which?</p>", note =>
|
|
{
|
|
note["type"] = "Question";
|
|
note["oneOf"] = new JsonArray(
|
|
new JsonObject { ["type"] = "Note", ["name"] = "this", ["replies"] = new JsonObject { ["type"] = "Collection", ["totalItems"] = 0 } },
|
|
new JsonObject { ["type"] = "Note", ["name"] = "that", ["replies"] = new JsonObject { ["type"] = "Collection", ["totalItems"] = 0 } });
|
|
note["endTime"] = DateTime.UtcNow.AddDays(1).ToString("O");
|
|
});
|
|
|
|
var voted = (await alice.Client.Post($"/api/v1/polls/{question.ID}/votes", ("choices[]", "1"))).Ok();
|
|
|
|
Assert.True(voted.Body.Flag("voted"));
|
|
var vote = Assert.Single(await bob.Delivered(since), d => d.Type() == "Create");
|
|
Assert.Equal("that", vote["object"].Text("name"));
|
|
Assert.Equal(question.ObjectURI, vote["object"].Text("inReplyTo"));
|
|
Assert.Null(vote["object"]!["published"]);
|
|
Assert.Equal(new[] { bob.Id }, Strings(vote["object"]!["to"]));
|
|
Assert.DoesNotContain(await carol.Delivered(since), d => d.Type() == "Create");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Deleting_a_reblog_undoes_it()
|
|
{
|
|
var since = DateTime.UtcNow.AddSeconds(-1);
|
|
var alice = await _host.Mastodon("alice");
|
|
var bob = new RemoteActor(_peer, "bob");
|
|
var remote = await _host.PublicPostFrom(bob, alice);
|
|
var reblog = (await alice.Client.Post($"/api/v1/statuses/{remote.ID}/reblog")).Ok().Body;
|
|
|
|
(await alice.Client.Delete($"/api/v1/statuses/{reblog.Text("id")}")).Ok();
|
|
|
|
Assert.False((await alice.Client.Get($"/api/v1/statuses/{remote.ID}")).Ok().Body.Flag("reblogged"));
|
|
Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Get($"/api/v1/statuses/{reblog.Text("id")}")).Status);
|
|
var undo = Assert.Single(await bob.Delivered(since), d => d.Type() == "Undo");
|
|
Assert.Equal("Announce", undo["object"].Text("type"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Many_statuses_are_fetched_by_id_in_one_call()
|
|
{
|
|
var alice = await _host.Mastodon("alice");
|
|
var first = await alice.Status("one");
|
|
var second = await alice.Status("two");
|
|
|
|
var answer = (await _host.Client().Get($"/api/v1/statuses?id[]={first.Text("id")}&id[]={second.Text("id")}&id[]=junk")).Ok();
|
|
|
|
Assert.Equal(new[] { first.Text("id"), second.Text("id") }, answer.Ids.Order());
|
|
Assert.Empty((await _host.Client().Get("/api/v1/statuses")).Ok().Array);
|
|
}
|
|
}
|
|
}
|