diff --git a/PrivaPub.Tests/Http/MastodonInstanceTests.cs b/PrivaPub.Tests/Http/MastodonInstanceTests.cs
index 361d770..fdf874a 100644
--- a/PrivaPub.Tests/Http/MastodonInstanceTests.cs
+++ b/PrivaPub.Tests/Http/MastodonInstanceTests.cs
@@ -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)
diff --git a/PrivaPub.Tests/Http/MastodonTagsTests.cs b/PrivaPub.Tests/Http/MastodonTagsTests.cs
new file mode 100644
index 0000000..ad558f1
--- /dev/null
+++ b/PrivaPub.Tests/Http/MastodonTagsTests.cs
@@ -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, $"
far away #{tag}
", 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);
+ }
+ }
+}
diff --git a/PrivaPub/Api/Mastodon/Controllers/SearchController.cs b/PrivaPub/Api/Mastodon/Controllers/SearchController.cs
index c759e8f..35e1755 100644
--- a/PrivaPub/Api/Mastodon/Controllers/SearchController.cs
+++ b/PrivaPub/Api/Mastodon/Controllers/SearchController.cs
@@ -105,9 +105,6 @@ namespace PrivaPub.Api.Mastodon.Controllers
[HttpGet("/api/v2/suggestions"), Scope("read")]
public IActionResult SuggestionsV2() => Json(Array.Empty