Public threads name their replies and context

Owner decision of 2026-10-06: a persona's public or unlisted post outside any group names its replies
(…/scribbles/{id}/replies) and its conversation's context (FEP-7888), the root's …/context that a reply inherits from
its parent, ours or another server's. Both list only the public and unlisted posts PrivaPub holds; followers-only,
circle, direct and local-only posts name neither and their collections answer 404. Checked live: opening alice's
thread on Mastodon finds carol's reply, which nobody there follows (scenarios/threads.sh).

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-06 07:13:54 +02:00
1 parent 93e13e5563
commit bf37223849
8 files changed
+152 -1

No files matched your search

@@ -0,0 +1,63 @@
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Net;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Http
{
// a public thread names its replies and its conversation (owner decision 2026-10-06), and they list only what is public
// or unlisted; a followers-only post names neither
[Trait("Category", "Integration")]
public sealed class ThreadCollectionsTests : IAsyncLifetime
{
PrivaPubHost _host;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_host = await PrivaPubHost.Shared();
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
static string PathOf(JsonObject status) => new Uri(status.Text("uri")).AbsolutePath;
static string[] Items(JsonNode collection) => collection["orderedItems"]!.AsArray().Select(i => i!.GetValue<string>()).ToArray();
[Fact]
public async Task A_public_post_names_its_replies_and_context_which_list_only_public_answers()
{
var alice = await _host.Mastodon("alice");
var carol = await _host.Mastodon("carol");
var root = await alice.Status("a public question");
var answer = await carol.Status("a public answer", ("in_reply_to_id", root.Text("id")));
var hidden = await carol.Status("for my followers", ("in_reply_to_id", root.Text("id")), ("visibility", "private"));
var deeper = await alice.Status("and a deeper one", ("in_reply_to_id", answer.Text("id")), ("visibility", "unlisted"));
var note = (await _host.ActivityPub(PathOf(root))).Ok().Object;
Assert.Equal(root.Text("uri") + "/replies", note.Text("replies"));
Assert.Equal(root.Text("uri") + "/context", note.Text("context"));
var answerNote = (await _host.ActivityPub(PathOf(deeper))).Ok().Object;
Assert.Equal(root.Text("uri") + "/context", answerNote.Text("context"));
var replies = (await _host.ActivityPub(PathOf(root) + "/replies")).Ok().Object;
Assert.Equal(new[] { answer.Text("uri") }, Items(replies));
var context = (await _host.ActivityPub(PathOf(root) + "/context")).Ok().Object;
Assert.Equal(new[] { root.Text("uri"), answer.Text("uri"), deeper.Text("uri") }, Items(context));
Assert.Equal(alice.Uri, context.Text("attributedTo"));
Assert.DoesNotContain(hidden.Text("uri"), Items(context));
}
[Fact]
public async Task A_followers_only_post_names_neither_and_serves_neither()
{
var alice = await _host.Mastodon("alice");
var quiet = await alice.Status("for my followers", ("visibility", "private"));
Assert.Equal(HttpStatusCode.NotFound, (await _host.ActivityPub(PathOf(quiet) + "/replies")).Status);
Assert.Equal(HttpStatusCode.NotFound, (await _host.ActivityPub(PathOf(quiet) + "/context")).Status);
}
}
}