diff --git a/PrivaPub.Tests/Http/MastodonTrendsTests.cs b/PrivaPub.Tests/Http/MastodonTrendsTests.cs index 777c0b9..54a35c5 100644 --- a/PrivaPub.Tests/Http/MastodonTrendsTests.cs +++ b/PrivaPub.Tests/Http/MastodonTrendsTests.cs @@ -103,12 +103,31 @@ namespace PrivaPub.Tests.Http await hidden.Status("not listed"); await alice.Status("listed"); - var active = (await _host.Client().Get("/api/v1/directory?order=active&local=true&limit=80")).Ok().Ids.ToList(); + // 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); - var joined = (await _host.Client().Get("/api/v1/directory?order=new&local=true&limit=80")).Ok().Ids.ToList(); - Assert.Contains(alice.Id, joined); - Assert.DoesNotContain(hidden.Id, joined); + // 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); } } } diff --git a/PrivaPub/Api/Mastodon/Controllers/TrendsController.cs b/PrivaPub/Api/Mastodon/Controllers/TrendsController.cs index 0fee6bd..dc36449 100644 --- a/PrivaPub/Api/Mastodon/Controllers/TrendsController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/TrendsController.cs @@ -95,15 +95,30 @@ namespace PrivaPub.Api.Mastodon.Controllers var ordered = new List(); if (Params.Get("order") == "new") { - var personas = (await _dbEntities.Avatars.Match(a => a.Settings.IsDiscoverable && !a.DeletionAt.HasValue && !barred.Contains(a.ID)) - .Sort(a => a.PublishedOn, Order.Descending).Limit(wanted * 4).ExecuteAsync(token)) - .Select(a => (a.ID, Joined: a.PublishedOn, Tie: Tie(a.UserName))); + // many share a day: the first `wanted` are read with every other of the last one's day, so each page is cut the same way + var personas = await _dbEntities.Avatars.Match(a => a.Settings.IsDiscoverable && !a.DeletionAt.HasValue && !barred.Contains(a.ID)) + .Sort(a => a.PublishedOn, Order.Descending).Limit(wanted).ExecuteAsync(token); + if (personas.Count == wanted) + { + var edge = personas[^1].PublishedOn; + var known = personas.Select(a => a.ID).ToList(); + personas.AddRange(await _dbEntities.Avatars.Match(a => a.Settings.IsDiscoverable && !a.DeletionAt.HasValue && !barred.Contains(a.ID) + && a.PublishedOn == edge && !known.Contains(a.ID)).ExecuteAsync(token)); + } var remote = localOnly - ? Enumerable.Empty<(string ID, DateTime Joined, string Tie)>() - : (await _dbEntities.ForeignAvatars.Match(f => f.IsDiscoverable && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue && f.Published != null) - .Sort(f => f.Published, Order.Descending).Limit(wanted).ExecuteAsync(token)) - .Select(f => (f.ID, Joined: f.Published.Value.Date, Tie: Tie(f.ActorURI))); - ordered = personas.Concat(remote).OrderByDescending(a => a.Joined).ThenBy(a => a.Tie, StringComparer.Ordinal) + ? new List() + : await _dbEntities.ForeignAvatars.Match(f => f.IsDiscoverable && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue && f.Published != null) + .Sort(f => f.Published, Order.Descending).Limit(wanted).ExecuteAsync(token); + if (remote.Count == wanted) + { + var edge = remote[^1].Published; + var known = remote.Select(f => f.ID).ToList(); + remote.AddRange(await _dbEntities.ForeignAvatars.Match(f => f.IsDiscoverable && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue + && f.Published == edge && !known.Contains(f.ID)).ExecuteAsync(token)); + } + ordered = personas.Select(a => (a.ID, Joined: a.PublishedOn, Tie: Tie(a.UserName))) + .Concat(remote.Select(f => (f.ID, Joined: f.Published.Value, Tie: Tie(f.ActorURI)))) + .OrderByDescending(a => a.Joined).ThenBy(a => a.Tie, StringComparer.Ordinal) .Select(a => a.ID).Skip(offset).Take(limit).ToList(); } else