Followed hashtags, and a tag's week of use

tags/:name shows the tag's last seven days in public posts PrivaPub holds
(uses and authors, as Mastodon gives them) and whether the persona follows
it; follow, unfollow and followed_tags replace the stubs. A public post that
is neither a boost nor a reply comes, as it arrives, to the homes of those
following one of its tags. Nothing is fetched for a followed tag and no
other server hears of it. Posts are indexed by tag.

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 03:04:27 +02:00
1 parent 864dae4469
commit 7bfd7ce285
9 files changed
+229 -13

No files matched your search

@@ -81,7 +81,6 @@ namespace PrivaPub.Tests.Http
[Theory]
[InlineData("/api/v1/suggestions")]
[InlineData("/api/v2/suggestions")]
[InlineData("/api/v1/followed_tags")]
[InlineData("/api/v1/endorsements")]
[InlineData("/api/v1/featured_tags")]
public async Task Signed_in_stub_routes_answer_empty_lists(string path)
+89
View File
@@ -0,0 +1,89 @@
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Net;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Http
{
// Hashtags: a tag's week of use in public posts, and followed tags, whose public posts come to the persona's home
[Trait("Category", "Integration")]
public sealed class MastodonTagsTests : 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 string NewTag() => $"t{Guid.NewGuid():N}"[..12];
[Fact]
public async Task A_tag_shows_its_week_of_public_use_and_whether_the_persona_follows_it()
{
var alice = await _host.Mastodon("alice");
var bob = await _host.Mastodon("bob");
var tag = NewTag();
await alice.Status($"about #{tag}");
await alice.Status($"more about #{tag.ToUpperInvariant()}");
await bob.Status($"also #{tag}");
await bob.Status($"quietly #{tag}", ("visibility", "unlisted"));
var shown = (await _host.Client().Get($"/api/v1/tags/{tag}")).Ok().Object;
Assert.Equal(tag, shown.Text("name"));
Assert.False(shown.Flag("following"));
var history = shown["history"]!.AsArray();
Assert.Equal(7, history.Count);
Assert.Equal(("3", "2"), (history[0].Text("uses"), history[0].Text("accounts")));
Assert.Equal(new DateTimeOffset(DateTime.UtcNow.Date, TimeSpan.Zero).ToUnixTimeSeconds().ToString(), history[0].Text("day"));
Assert.True((await alice.Client.Post($"/api/v1/tags/{tag}/follow")).Ok().Body.Flag("following"));
Assert.True((await alice.Client.Get($"/api/v1/tags/%23{tag.ToUpperInvariant()}")).Ok().Body.Flag("following"));
Assert.Equal(new[] { tag }, (await alice.Client.Get("/api/v1/followed_tags")).Ok().Array.Select(t => t.Text("name")));
Assert.False((await bob.Client.Get($"/api/v1/tags/{tag}")).Ok().Body.Flag("following"));
Assert.False((await alice.Client.Post($"/api/v1/tags/{tag}/unfollow")).Ok().Body.Flag("following"));
Assert.Empty((await alice.Client.Get("/api/v1/followed_tags")).Ok().Array);
Assert.Equal(HttpStatusCode.Unauthorized, (await _host.Client().Post($"/api/v1/tags/{tag}/follow")).Status);
}
[Fact]
public async Task A_followed_tags_public_posts_come_home_from_here_and_from_other_servers()
{
var alice = await _host.Mastodon("alice");
var bob = await _host.Mastodon("bob");
var carol = await _host.Mastodon("carol");
var remote = new RemoteActor(_peer, "dora");
var tag = NewTag();
(await alice.Client.Post($"/api/v1/tags/{tag}/follow")).Ok();
var open = (await bob.Status($"news about #{tag}")).Text("id");
var quiet = (await bob.Status($"quiet news #{tag}", ("visibility", "unlisted"))).Text("id");
var answer = (await bob.Status($"and #{tag} again", ("in_reply_to_id", open))).Text("id");
var untagged = (await bob.Status("nothing tagged")).Text("id");
var far = await _host.PublicPostFrom(remote, carol, $"<p>far away #{tag}</p>", note => note["tag"]!.AsArray().Add(new JsonObject
{
["type"] = "Hashtag",
["name"] = "#" + tag,
["href"] = $"{remote.Origin()}/tags/{tag}"
}));
var home = (await alice.Client.Get("/api/v1/timelines/home?limit=40")).Ok().Ids.ToList();
Assert.Contains(open, home);
Assert.Contains(far.ID, home);
Assert.DoesNotContain(quiet, home);
Assert.DoesNotContain(answer, home);
Assert.DoesNotContain(untagged, home);
Assert.DoesNotContain(open, (await carol.Client.Get("/api/v1/timelines/home?limit=40")).Ok().Ids);
}
}
}