Filters: words and posts a persona would rather not see
Mastodon's v2 filters replace the empty stubs: a filter's title, contexts, action (warn, hide, blur) and expiry, its keywords (whole words or not, taken as JSON objects, listed or numbered form fields, with id and _destroy on update) and its statuses, each with their own endpoints; the v1 API is the same filters seen keyword by keyword. Every status a persona reads carries the filters it matches in `filtered` (a boost as what it boosts), matched as Mastodon matches: warning, title, text, poll options and media descriptions. Clients apply context and action. Filters never federate, and go with a deleted persona. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
1 parent
b710493701
commit
5b3e456e09
12 files changed
+759
-13
No files matched your search
@@ -0,0 +1,169 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Tests.Support;
|
||||
using PrivaPub.Tests.Support.Host;
|
||||
|
||||
using System.Net;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace PrivaPub.Tests.Http
|
||||
{
|
||||
// Mastodon's filters: a persona's v2 filters with keywords and statuses, the v1 API over their keywords, and the
|
||||
// `filtered` each status carries for the filters it matches
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class MastodonFiltersTests : IAsyncLifetime
|
||||
{
|
||||
PrivaPubHost _host;
|
||||
|
||||
static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
_host = await PrivaPubHost.Shared();
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
static Task<ApiAnswer> Create(Mastodon owner, string title, string action, params (string Keyword, bool WholeWord)[] keywords) =>
|
||||
owner.Client.Json(HttpMethod.Post, "/api/v2/filters", new JsonObject
|
||||
{
|
||||
["title"] = title,
|
||||
["context"] = new JsonArray("home", "public", "thread"),
|
||||
["filter_action"] = action,
|
||||
["keywords_attributes"] = new JsonArray(keywords.Select(k => (JsonNode)new JsonObject { ["keyword"] = k.Keyword, ["whole_word"] = k.WholeWord }).ToArray())
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public async Task A_filter_is_made_changed_and_deleted_by_its_owner_only()
|
||||
{
|
||||
var alice = await _host.Mastodon("alice");
|
||||
var mallory = await _host.Mastodon("mallory");
|
||||
|
||||
var made = (await Create(alice, "Spoilers", "hide", ("spoiler", true), ("leak", false))).Ok().Object;
|
||||
var id = made.Text("id");
|
||||
Assert.Equal("hide", made.Text("filter_action"));
|
||||
Assert.Equal(new[] { "home", "public", "thread" }, made["context"]!.AsArray().Select(c => c!.GetValue<string>()));
|
||||
var keywords = made["keywords"]!.AsArray();
|
||||
Assert.Equal(new[] { "spoiler", "leak" }, keywords.Select(k => k.Text("keyword")));
|
||||
Assert.Equal(new[] { true, false }, keywords.Select(k => k!.Flag("whole_word")));
|
||||
Assert.Null(made["expires_at"]);
|
||||
|
||||
var changed = (await alice.Client.Json(HttpMethod.Put, $"/api/v2/filters/{id}", new JsonObject
|
||||
{
|
||||
["title"] = "Leaks",
|
||||
["filter_action"] = "warn",
|
||||
["expires_in"] = 3600,
|
||||
["keywords_attributes"] = new JsonArray(
|
||||
new JsonObject { ["id"] = keywords[0].Text("id"), ["_destroy"] = true },
|
||||
new JsonObject { ["keyword"] = "ending" })
|
||||
})).Ok().Object;
|
||||
Assert.Equal("Leaks", changed.Text("title"));
|
||||
Assert.Equal("warn", changed.Text("filter_action"));
|
||||
Assert.NotNull(changed.Text("expires_at"));
|
||||
Assert.Equal(new[] { "leak", "ending" }, changed["keywords"]!.AsArray().Select(k => k.Text("keyword")));
|
||||
|
||||
var added = (await alice.Client.Post($"/api/v2/filters/{id}/keywords", ("keyword", "finale"), ("whole_word", "false"))).Ok().Object;
|
||||
Assert.False(added.Flag("whole_word"));
|
||||
var keywordId = added.Text("id");
|
||||
Assert.Equal("finale", (await alice.Client.Get($"/api/v2/filters/keywords/{keywordId}")).Ok().Object.Text("keyword"));
|
||||
Assert.Equal("last scene", (await alice.Client.Put($"/api/v2/filters/keywords/{keywordId}", ("keyword", "last scene"))).Ok().Object.Text("keyword"));
|
||||
(await alice.Client.Delete($"/api/v2/filters/keywords/{keywordId}")).Ok();
|
||||
Assert.Equal(2, (await alice.Client.Get($"/api/v2/filters/{id}/keywords")).Ok().Array.Count);
|
||||
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await Create(alice, " ", "warn", ("x", true))).Status);
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await Create(alice, "x", "explode", ("x", true))).Status);
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await alice.Client.Post("/api/v2/filters", ("title", "x"), ("context[]", "everywhere"))).Status);
|
||||
Assert.Empty((await mallory.Client.Get("/api/v2/filters")).Ok().Array);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await mallory.Client.Get($"/api/v2/filters/{id}")).Status);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await mallory.Client.Delete($"/api/v2/filters/keywords/{changed["keywords"]![0].Text("id")}")).Status);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await _host.Client().Get("/api/v2/filters")).Status);
|
||||
|
||||
(await alice.Client.Delete($"/api/v2/filters/{id}")).Ok();
|
||||
Assert.Empty((await alice.Client.Get("/api/v2/filters")).Ok().Array);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Statuses_carry_the_filters_they_match_by_word_and_by_id()
|
||||
{
|
||||
var alice = await _host.Mastodon("alice");
|
||||
var bob = await _host.Mastodon("bob");
|
||||
(await bob.Client.Post($"/api/v1/accounts/{alice.Id}/follow")).Ok();
|
||||
var filterId = (await Create(alice, "Spoilers", "warn", ("spoiler", true), ("#tv", true))).Ok().Object.Text("id");
|
||||
var other = (await Create(bob, "Not mine", "hide", ("spoiler", false))).Ok().Object.Text("id");
|
||||
var whole = (await alice.Status("a big SPOILER ahead")).Text("id");
|
||||
var partial = (await alice.Status("spoilers galore")).Text("id");
|
||||
var tagged = (await alice.Status("watching #TV tonight")).Text("id");
|
||||
var plain = (await alice.Status("nothing to see")).Text("id");
|
||||
var warned = (await alice.Status("calm text", ("spoiler_text", "spoiler inside"))).Text("id");
|
||||
var byId = (await alice.Client.Post($"/api/v2/filters/{filterId}/statuses", ("status_id", plain))).Ok().Object;
|
||||
Assert.Equal(plain, byId.Text("status_id"));
|
||||
var boost = (await bob.Client.Post($"/api/v1/statuses/{whole}/reblog")).Ok().Object.Text("id");
|
||||
|
||||
var home = (await alice.Client.Get("/api/v1/timelines/home?limit=40")).Ok().Array.ToDictionary(s => s.Text("id"), s => s!["filtered"]!.AsArray());
|
||||
var match = Assert.Single(home[whole]);
|
||||
Assert.Equal(filterId, match!["filter"].Text("id"));
|
||||
Assert.Equal("warn", match["filter"].Text("filter_action"));
|
||||
Assert.Equal(new[] { "spoiler" }, match["keyword_matches"]!.AsArray().Select(k => k!.GetValue<string>()));
|
||||
Assert.Empty(home[partial]);
|
||||
Assert.Equal(new[] { "#tv" }, Assert.Single(home[tagged])!["keyword_matches"]!.AsArray().Select(k => k!.GetValue<string>()));
|
||||
Assert.Equal(new[] { byId.Text("id") }, Assert.Single(home[plain])!["status_matches"]!.AsArray().Select(k => k!.GetValue<string>()));
|
||||
Assert.Single(home[warned]);
|
||||
Assert.Single((await alice.Client.Get($"/api/v1/statuses/{boost}")).Ok().Body["filtered"]!.AsArray());
|
||||
|
||||
var asBob = (await bob.Client.Get($"/api/v1/statuses/{partial}")).Ok().Body["filtered"]!.AsArray();
|
||||
Assert.Equal(other, Assert.Single(asBob)!["filter"].Text("id"));
|
||||
Assert.Empty((await _host.Client().Get($"/api/v1/statuses/{whole}")).Ok().Body["filtered"]!.AsArray());
|
||||
|
||||
// an expired filter matches nothing
|
||||
await DB.Default.Update<PersonaFilter>().MatchID(filterId).Modify(f => f.ExpiresAt, DateTime.UtcNow.AddMinutes(-1)).ExecuteAsync(Token);
|
||||
Assert.Empty((await alice.Client.Get($"/api/v1/statuses/{whole}")).Ok().Body["filtered"]!.AsArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Keywords_come_as_form_fields_listed_or_numbered()
|
||||
{
|
||||
var alice = await _host.Mastodon("alice");
|
||||
|
||||
var listed = (await alice.Client.Post("/api/v2/filters", ("title", "Listed"), ("context[]", "home"),
|
||||
("keywords_attributes[][keyword]", "one"), ("keywords_attributes[][whole_word]", "false"),
|
||||
("keywords_attributes[][keyword]", "two"), ("keywords_attributes[][whole_word]", "true"))).Ok().Object;
|
||||
Assert.Equal(new[] { ("one", false), ("two", true) },
|
||||
listed["keywords"]!.AsArray().Select(k => (k.Text("keyword"), k!.Flag("whole_word"))));
|
||||
|
||||
var numbered = (await alice.Client.Post("/api/v2/filters", ("title", "Numbered"), ("context[]", "public"),
|
||||
("keywords_attributes[1][keyword]", "second"), ("keywords_attributes[0][keyword]", "first"),
|
||||
("keywords_attributes[0][whole_word]", "false"))).Ok().Object;
|
||||
Assert.Equal(new[] { ("first", false), ("second", true) },
|
||||
numbered["keywords"]!.AsArray().Select(k => (k.Text("keyword"), k!.Flag("whole_word"))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_v1_filter_is_one_keyword_of_a_v2_filter()
|
||||
{
|
||||
var alice = await _host.Mastodon("alice");
|
||||
|
||||
var made = (await alice.Client.Post("/api/v1/filters", ("phrase", "election"), ("context[]", "home"), ("context[]", "notifications"),
|
||||
("irreversible", "true"), ("whole_word", "false"))).Ok().Object;
|
||||
var id = made.Text("id");
|
||||
Assert.Equal("election", made.Text("phrase"));
|
||||
Assert.True(made.Flag("irreversible"));
|
||||
Assert.False(made.Flag("whole_word"));
|
||||
var v2 = Assert.Single((await alice.Client.Get("/api/v2/filters")).Ok().Array)!;
|
||||
Assert.Equal("election", v2.Text("title"));
|
||||
Assert.Equal("hide", v2.Text("filter_action"));
|
||||
Assert.Equal(id, Assert.Single(v2["keywords"]!.AsArray()).Text("id"));
|
||||
|
||||
var changed = (await alice.Client.Put($"/api/v1/filters/{id}", ("phrase", "elections"), ("irreversible", "false"))).Ok().Object;
|
||||
Assert.Equal("elections", changed.Text("phrase"));
|
||||
Assert.False(changed.Flag("irreversible"));
|
||||
Assert.Equal(new[] { id }, (await alice.Client.Get("/api/v1/filters")).Ok().Ids);
|
||||
Assert.Equal("elections", (await alice.Client.Get($"/api/v1/filters/{id}")).Ok().Object.Text("phrase"));
|
||||
|
||||
(await alice.Client.Delete($"/api/v1/filters/{id}")).Ok();
|
||||
Assert.Empty((await alice.Client.Get("/api/v2/filters")).Ok().Array);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await alice.Client.Get($"/api/v1/filters/{id}")).Status);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,8 +79,6 @@ namespace PrivaPub.Tests.Http
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/api/v1/filters")]
|
||||
[InlineData("/api/v2/filters")]
|
||||
[InlineData("/api/v1/suggestions")]
|
||||
[InlineData("/api/v2/suggestions")]
|
||||
[InlineData("/api/v1/followed_tags")]
|
||||
|
||||
Reference in new issue
Block a user