From 71ca134599ff9fd18946f83652bab72ec101d402 Mon Sep 17 00:00:00 2001 From: thepra Date: Mon, 5 Oct 2026 03:35:10 +0200 Subject: [PATCH] The directory's pages are cut the same way every time Many personas share a published day, and a page cut through such a day could take a different few of them each time, skipping some between two pages: the cut now takes the whole day at its edge. The test reads the directory page by page until it finds its persona and checks each page's order, since other tests make personas between two pages. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- PrivaPub.Tests/Http/MastodonTrendsTests.cs | 27 +++++++++++++--- .../Mastodon/Controllers/TrendsController.cs | 31 ++++++++++++++----- 2 files changed, 46 insertions(+), 12 deletions(-) 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