A handle finds its account, not posts and a made-up hashtag

Since status search by words, searching "@name@host" also answered posts
sharing the handle's words and a hashtag "#@name@host" made of the query,
so a client that opens the profile when the account is the only result
(decePub's search) stayed on the list. A handle now searches accounts only,
and a hashtag result is offered only when the query is one (letters, marks,
digits and underscores, with a letter).

Found by decePub's end-to-end tests: every profile follow test failed.

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 06:10:25 +02:00
1 parent 17922f4dfd
commit 4a9a3d3235
3 files changed
+31 -3

No files matched your search

@@ -46,5 +46,23 @@ namespace PrivaPub.Tests.Http
Assert.Equal(new[] { named },
(await reader.Client.Get($"/api/v2/search?q={word}&type=statuses&limit=1&offset=1")).Ok().Body["statuses"]!.AsArray().Select(s => s.Text("id")));
}
// a handle asks for its account: no posts sharing its words, and no hashtag made of it
[Fact]
public async Task A_handle_finds_its_account_and_neither_posts_nor_a_hashtag()
{
var reader = await _host.Mastodon("reader");
var named = await _host.Mastodon("named");
await reader.Status($"talking of {named.UserName} here");
foreach (var q in new[] { $"@{named.UserName}", $"@{named.UserName}@{PrivaPubHost.Host}", $"{named.UserName}@{PrivaPubHost.Host}" })
{
var body = (await reader.Client.Get($"/api/v2/search?q={Uri.EscapeDataString(q)}")).Ok().Body;
Assert.Contains(body["accounts"]!.AsArray(), a => a.Text("id") == named.Id);
Assert.Empty(body["statuses"]!.AsArray());
Assert.Empty(body["hashtags"]!.AsArray());
}
Assert.Equal("cats", Assert.Single((await reader.Client.Get("/api/v2/search?q=%23Cats")).Ok().Body["hashtags"]!.AsArray()).Text("name"));
}
}
}
@@ -73,10 +73,11 @@ namespace PrivaPub.Api.Mastodon.Controllers
{
results.Accounts = await _search.Find(q, resolve, Limit(), Math.Max(0, offset), token);
}
if (type is null or "statuses" && MyId != default && !q.StartsWith('#'))
// an account's handle asks for the account, never for posts that happen to share its words
if (type is null or "statuses" && MyId != default && !q.StartsWith('#') && !Handle().IsMatch(q))
results.Statuses = await Words(q, Math.Max(0, offset), Limit(), token);
if (type is null or "hashtags" && q.TrimStart('#') is { Length: > 0 } tag)
results.Hashtags.Add(new { name = tag.ToLowerInvariant(), url = $"{_localActors.BaseAddress}/tags/{Uri.EscapeDataString(tag.ToLowerInvariant())}", history = Array.Empty<object>() });
if (type is null or "hashtags" && TagsController.Normalise(q) is var tag && TagsController.IsHashtag(tag))
results.Hashtags.Add(new { name = tag, url = $"{_localActors.BaseAddress}/tags/{Uri.EscapeDataString(tag)}", history = Array.Empty<object>() });
return Json(results);
}
}
@@ -85,6 +86,9 @@ namespace PrivaPub.Api.Mastodon.Controllers
{
const int WordCandidates = 400;
[System.Text.RegularExpressions.GeneratedRegex(@"^@?[^\s@]+@[^\s@]+$|^@[^\s@]+$")]
private static partial System.Text.RegularExpressions.Regex Handle();
// Posts by their words, as Mastodon searches them: those the persona wrote, boosted, favourited, bookmarked or was
// named in, and public posts of authors who let themselves be indexed; newest first, what the persona may see only
async Task<List<Status>> Words(string q, int offset, int limit, CancellationToken token)
@@ -31,6 +31,12 @@ namespace PrivaPub.Api.Mastodon.Controllers
public static string Normalise(string name) => name?.Trim().TrimStart('#').Normalize(NormalizationForm.FormKC).ToLowerInvariant() ?? string.Empty;
// what a hashtag can be, as Mastodon has it: letters, marks, digits and underscores, with a letter or underscore
public static bool IsHashtag(string normal) =>
normal.Length is > 0 and <= 100 && normal.All(c => char.IsLetterOrDigit(c) || c == '_' || char.GetUnicodeCategory(c) is
System.Globalization.UnicodeCategory.NonSpacingMark or System.Globalization.UnicodeCategory.SpacingCombiningMark)
&& normal.Any(c => char.IsLetter(c) || c == '_');
// a day's uses and authors, newest first, for the last week, as Mastodon gives them (strings, days as unix time)
public static async Task<List<object>> History(DbEntities dbEntities, string tag, CancellationToken token)
{