Files
SocialPub/PrivaPub.Tests/Http/MastodonWordSearchTests.cs
thepraandClaude Opus 5.5 fb4949b511
Build / Build (push) Successful in 7m58s
P7: FEP-268d searchableBy decides what search finds
A remote account's searchableBy, and a post's own, outrank indexable in status search: Public lets anyone find a
public post, the author's followers only those who follow it, anything else nobody but those it already reaches
(their own, named, favourited, bookmarked or boosted posts). Read on actors and posts, kept through edits; PrivaPub
does not emit it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-07 21:17:36 +02:00

136 lines
6.5 KiB
C#

using MongoDB.Entities;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Http
{
// Status search by words, as Mastodon scopes it: the persona's own posts and those it boosted, favourited, bookmarked
// or was named in, and public posts of authors who let themselves be indexed
[Trait("Category", "Integration")]
public sealed class MastodonWordSearchTests : IAsyncLifetime
{
PrivaPubHost _host;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_host = await PrivaPubHost.Shared();
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
[Fact]
public async Task Words_find_what_the_persona_wrote_or_touched_and_what_indexable_authors_made_public()
{
var reader = await _host.Mastodon("reader");
var open = await _host.Mastodon("open");
var shy = await _host.Mastodon("shy");
(await open.Client.Patch("/api/v1/accounts/update_credentials", ("indexable", "true"))).Ok();
var word = $"w{Guid.NewGuid():N}"[..12];
var own = (await reader.Status($"my {word}")).Text("id");
var indexed = (await open.Status($"open {word}")).Text("id");
var quietIndexed = (await open.Status($"followers {word}", ("visibility", "private"))).Text("id");
var unindexed = (await shy.Status($"shy {word}")).Text("id");
var liked = (await shy.Status($"liked {word}")).Text("id");
var named = (await shy.Status($"@{reader.UserName} about {word}")).Text("id");
var warned = (await shy.Status("calm", ("spoiler_text", $"about {word}"))).Text("id");
(await reader.Client.Post($"/api/v1/statuses/{liked}/favourite")).Ok();
(await reader.Client.Post($"/api/v1/statuses/{warned}/bookmark")).Ok();
var found = (await reader.Client.Get($"/api/v2/search?q={word}&type=statuses&limit=40")).Ok().Body["statuses"]!.AsArray()
.Select(s => s.Text("id")).ToList();
Assert.Equal(new[] { warned, named, liked, indexed, own }, found);
Assert.DoesNotContain(unindexed, found);
Assert.DoesNotContain(quietIndexed, found);
Assert.Empty((await _host.Client().Get($"/api/v2/search?q={word}&type=statuses")).Ok().Body["statuses"]!.AsArray());
Assert.Equal(new[] { named },
(await reader.Client.Get($"/api/v2/search?q={word}&type=statuses&limit=1&offset=1")).Ok().Body["statuses"]!.AsArray().Select(s => s.Text("id")));
}
// FEP-268d: a remote account's searchableBy outranks its indexable, and a post's own outranks its author's
[Fact]
public async Task A_remote_searchable_by_decides_before_indexable()
{
var token = TestContext.Current.CancellationToken;
await using var peer = await Peer.Start();
var reader = await _host.Mastodon("reader");
var keeper = await _host.Mastodon("keeper");
var word = $"s{Guid.NewGuid():N}"[..12];
async Task<RemoteActor> Author(string name, bool indexable, Func<RemoteActor, JsonArray> searchableBy)
{
var author = new RemoteActor(peer, name);
var document = author.Document();
document["indexable"] = indexable;
if (searchableBy != default)
document["searchableBy"] = searchableBy(author);
peer.Serve($"/users/{author.Name}", document.ToJsonString());
await DB.Default.SaveAsync(new Following { AvatarId = keeper.Id, TargetActorURI = author.Id, State = FollowState.Accepted }, token);
return author;
}
async Task<string> Says(RemoteActor author, string text, JsonArray searchableBy = default)
{
var noteId = $"{peer.A}/notes/{Guid.NewGuid():N}";
var note = new JsonObject
{
["id"] = noteId, ["type"] = "Note", ["attributedTo"] = author.Id, ["content"] = $"<p>{text}</p>",
["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(author.Id + "/followers"), ["published"] = DateTime.UtcNow.ToString("O")
};
if (searchableBy != default)
note["searchableBy"] = searchableBy;
using var client = _host.Client();
await client.SendAsync(author.SignedPost("/human-centipede", new JsonObject
{
["id"] = noteId + "/activity", ["type"] = "Create", ["actor"] = author.Id,
["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(author.Id + "/followers"), ["object"] = note
}), token);
Assert.Equal(1, await _host.RunInbox(noteId + "/activity", token));
return (await DB.Default.Find<Models.Post.Post>().Match(p => p.ObjectURI == noteId).ExecuteFirstAsync(token)).ID;
}
var open = await Says(await Author("open", indexable: false, _ => new JsonArray(Addressing.Public)), $"open {word}");
var closed = await Says(await Author("closed", indexable: true, _ => new JsonArray()), $"closed {word}");
var friendly = await Author("friendly", indexable: false, a => new JsonArray(a.Id + "/followers"));
var friends = await Says(friendly, $"friends {word}");
var quiet = await Author("quiet", indexable: false, default);
var perPost = await Says(quiet, $"per post {word}", new JsonArray(Addressing.Public));
var plain = await Says(quiet, $"plain {word}");
static List<string> Found(JsonNode body) => body["statuses"]!.AsArray().Select(s => s.Text("id")).ToList();
var before = Found((await reader.Client.Get($"/api/v2/search?q={word}&type=statuses&limit=40")).Ok().Body);
Assert.Contains(open, before);
Assert.Contains(perPost, before);
Assert.DoesNotContain(closed, before);
Assert.DoesNotContain(friends, before);
Assert.DoesNotContain(plain, before);
await DB.Default.SaveAsync(new Following { AvatarId = reader.Id, TargetActorURI = friendly.Id, State = FollowState.Accepted }, token);
Assert.Contains(friends, Found((await reader.Client.Get($"/api/v2/search?q={word}&type=statuses&limit=40")).Ok().Body));
}
// a handle asks for its account: no posts sharing its words, and no hashtag made of it
[Fact]
public async Task A_handle_finds_its_account_and_neither_posts_nor_a_hashtag()
{
var reader = await _host.Mastodon("reader");
var named = await _host.Mastodon("named");
await reader.Status($"talking of {named.UserName} here");
foreach (var q in new[] { $"@{named.UserName}", $"@{named.UserName}@{PrivaPubHost.Host}", $"{named.UserName}@{PrivaPubHost.Host}" })
{
var body = (await reader.Client.Get($"/api/v2/search?q={Uri.EscapeDataString(q)}")).Ok().Body;
Assert.Contains(body["accounts"]!.AsArray(), a => a.Text("id") == named.Id);
Assert.Empty(body["statuses"]!.AsArray());
Assert.Empty(body["hashtags"]!.AsArray());
}
Assert.Equal("cats", Assert.Single((await reader.Client.Get("/api/v2/search?q=%23Cats")).Ok().Body["hashtags"]!.AsArray()).Text("name"));
}
}
}