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:
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user