using MongoDB.Entities; using PrivaPub.Domain.Social; using PrivaPub.Models.Post; using PrivaPub.Tests.Support; using PrivaPub.Tests.Support.Host; namespace PrivaPub.Tests.Http { // Trends from what PrivaPub holds (hashtags and links by how many authors use them, posts by their interactions, // from discoverable authors only) and the directory of accounts that let themselves be discovered [Trait("Category", "Integration")] public sealed class MastodonTrendsTests : 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; void Fresh() => _host.Get().Forget(); static string NewTag() => $"tr{Guid.NewGuid():N}"[..12]; [Fact] public async Task A_hashtag_trends_once_two_authors_use_it_today_and_never_from_one_or_behind_a_warning() { var alice = await _host.Mastodon("alice"); var bob = await _host.Mastodon("bob"); var carol = await _host.Mastodon("carol"); var shared = NewTag(); var lonely = NewTag(); var warned = NewTag(); await alice.Status($"#{shared} is good"); await bob.Status($"#{shared} indeed"); for (var i = 0; i < 4; i++) await carol.Status($"#{lonely} again {i}"); await alice.Status($"#{warned}", ("spoiler_text", "careful")); await bob.Status($"#{warned}", ("spoiler_text", "careful")); Fresh(); var trends = (await _host.Client().Get("/api/v1/trends/tags?limit=20")).Ok().Array; var trend = Assert.Single(trends, t => t.Text("name") == shared)!; Assert.Equal(("2", "2"), (trend["history"]![0].Text("uses"), trend["history"]![0].Text("accounts"))); Assert.DoesNotContain(trends, t => t.Text("name") == lonely); Assert.DoesNotContain(trends, t => t.Text("name") == warned); Assert.Contains((await alice.Client.Get("/api/v1/trends?limit=20")).Ok().Array, t => t.Text("name") == shared); } [Fact] public async Task A_post_trends_by_its_interactions_when_its_author_is_discoverable() { var alice = await _host.Mastodon("alice"); var hidden = await _host.Mastodon("hidden"); var bob = await _host.Mastodon("bob"); (await hidden.Client.Patch("/api/v1/accounts/update_credentials", ("discoverable", "false"))).Ok(); var liked = (await alice.Status("worth a like")).Text("id"); var quiet = (await hidden.Status("worth a like too")).Text("id"); var answer = (await alice.Status("a reply", ("in_reply_to_id", liked))).Text("id"); foreach (var id in new[] { liked, quiet, answer }) { (await bob.Client.Post($"/api/v1/statuses/{id}/favourite")).Ok(); (await bob.Client.Post($"/api/v1/statuses/{id}/reblog")).Ok(); } Fresh(); var trending = (await _host.Client().Get("/api/v1/trends/statuses?limit=40")).Ok().Ids.ToList(); Assert.Contains(liked, trending); Assert.DoesNotContain(quiet, trending); Assert.DoesNotContain(answer, trending); } [Fact] public async Task A_link_trends_once_two_authors_share_it() { var alice = await _host.Mastodon("alice"); var bob = await _host.Mastodon("bob"); var url = $"https://news.example/{Guid.NewGuid():N}"; foreach (var account in new[] { alice, bob }) { var id = (await account.Status($"read {url}")).Text("id"); await DB.Default.Update().MatchID(id).Modify(p => p.Link, new PostLink { Href = url, Title = "The news" }).ExecuteAsync(Token); } Fresh(); var link = Assert.Single((await _host.Client().Get("/api/v1/trends/links?limit=20")).Ok().Array, l => l.Text("url") == url)!; Assert.Equal("The news", link.Text("title")); Assert.Equal("2", link["history"]![0].Text("accounts")); } [Fact] public async Task The_directory_lists_discoverable_accounts_by_activity_or_by_joining_and_never_by_creation() { var alice = await _host.Mastodon("alice"); var hidden = await _host.Mastodon("hidden"); (await hidden.Client.Patch("/api/v1/accounts/update_credentials", ("discoverable", "false"))).Ok(); await hidden.Status("not listed"); await alice.Status("listed"); // by their latest public post, newest first: other tests post too, so the directory is read until alice var active = new List(); for (var offset = 0; offset < 4000 && !active.Contains(alice.Id); offset += 80) { var page = (await _host.Client().Get($"/api/v1/directory?order=active&local=true&limit=80&offset={offset}")).Ok().Ids.ToList(); active.AddRange(page); if (page.Count < 80) break; } Assert.Contains(alice.Id, active); Assert.DoesNotContain(hidden.Id, active); // by the day each says it joined, newest first: a persona's is its published day, set some days back // (each page in order: other tests make personas between two pages) var joined = new List(); for (var offset = 0; offset < 4000; offset += 80) { var page = (await _host.Client().Get($"/api/v1/directory?order=new&local=true&limit=80&offset={offset}")).Ok().Array; var days = page.Select(a => a.Text("created_at")[..10]).ToList(); Assert.Equal(days.OrderByDescending(d => d, StringComparer.Ordinal), days); joined.AddRange(page!); if (page.Count < 80 || joined.Any(a => a.Text("id") == alice.Id)) break; } Assert.Contains(joined, a => a.Text("id") == alice.Id); Assert.DoesNotContain(joined, a => a.Text("id") == hidden.Id); } } }