P7: thread backfill reads conversation containers and asks with If-None-Match
Build / Build (push) Failing after 7m52s
Build / Build (push) Failing after 7m52s
The FetchReplies job reads a thread's FEP-171b contextHistory before its FEP-7888 context: the posts each Add (or Forte's plain Create) brought in, read from their own servers. A thread collection read whole keeps its ETag when the document itself changes with every post (it counts them, or holds them all with no further page); the next read sends it as If-None-Match and a 304 ends the job. The ETag is kept as sent, since NodeBB's has no quotes and the typed header drops it. A context naming a post we hold (Forte's first post) is not fetched. Checked in the pasture: Mastodon 4.7.3 answers the second read 304; NodeBB 4.16's unquoted ETag is kept (23/23 in its scenario); a Forte thread completes through its replies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
1 parent
fb4949b511
commit
62189bf11d
10 files changed
+268
-35
No files matched your search
@@ -216,6 +216,103 @@ namespace PrivaPub.Tests.Federation
|
||||
Assert.All(_harness.Peer.Requests.Where(r => r.Path.EndsWith("/replies")), r => Assert.False(string.IsNullOrEmpty(r.Signature)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_conversation_container_is_read_before_the_context_and_only_its_added_posts_taken()
|
||||
{
|
||||
var owner = new RemoteActor(_harness.Peer, "owner");
|
||||
var answerer = new RemoteActor(_harness.Peer, "answerer");
|
||||
var root = PublicNote(owner, "<p>a conversation</p>");
|
||||
var history = NewId(owner, "conversations");
|
||||
root["contextHistory"] = history;
|
||||
root["context"] = NewId(owner, "never");
|
||||
Served(root);
|
||||
var embedded = Served(Reply(answerer, "added whole", root));
|
||||
var referenced = Served(Reply(owner, "added by reference", embedded));
|
||||
var liked = Served(Reply(answerer, "only liked", root));
|
||||
var target = new JsonObject { ["type"] = "OrderedCollection", ["id"] = history, ["attributedTo"] = owner.Id };
|
||||
JsonObject Add(JsonNode activity) => new()
|
||||
{
|
||||
["id"] = NewId(owner, "activities"), ["type"] = "Add", ["actor"] = owner.Id, ["object"] = activity, ["target"] = target.DeepClone()
|
||||
};
|
||||
var create = Served(Create(owner, referenced));
|
||||
create["object"] = IdOf(referenced);
|
||||
Served(create);
|
||||
var byReference = Served(Add(IdOf(create)));
|
||||
Served(new JsonObject
|
||||
{
|
||||
["id"] = history, ["type"] = "OrderedCollection", ["attributedTo"] = owner.Id, ["collectionOf"] = "Activity",
|
||||
["orderedItems"] = new JsonArray(Create(owner, root), Add(Create(answerer, embedded)), IdOf(byReference), Add(Activity(answerer, "Like", JsonValue.Create(IdOf(liked)))))
|
||||
});
|
||||
var held = await Held(root);
|
||||
|
||||
Assert.Equal(1, await RunReplies(held));
|
||||
var stored = await Stored(embedded, referenced, liked);
|
||||
Assert.Equal(new[] { IdOf(embedded), IdOf(referenced) }.Order(), stored.Keys.Order());
|
||||
Assert.Equal(held.ID, stored[IdOf(embedded)].AnsweringToPostId);
|
||||
Assert.Equal(stored[IdOf(embedded)].ID, stored[IdOf(referenced)].AnsweringToPostId);
|
||||
Assert.DoesNotContain(_harness.Peer.Requests, r => r.Path == new Uri(root["context"]!.GetValue<string>()).AbsolutePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_unchanged_context_is_not_read_again_while_it_counts_the_thread()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var poster = new RemoteActor(_harness.Peer, "poster");
|
||||
var answerer = new RemoteActor(_harness.Peer, "answerer");
|
||||
var root = PublicNote(poster, "<p>a topic</p>");
|
||||
var context = NewId(poster, "topics");
|
||||
var path = new Uri(context).AbsolutePath;
|
||||
root["context"] = context;
|
||||
Served(root);
|
||||
var first = Served(Reply(answerer, "first", root));
|
||||
var later = Served(Reply(poster, "later", root));
|
||||
// NodeBB's: every post inline, counted, and an ETag without quotes
|
||||
JsonObject Thread(params JsonObject[] notes) => new()
|
||||
{
|
||||
["id"] = context, ["type"] = "OrderedCollection", ["totalItems"] = notes.Length,
|
||||
["orderedItems"] = new JsonArray(notes.Select(n => (JsonNode)IdOf(n)).ToArray())
|
||||
};
|
||||
_harness.Peer.ServeTagged(path, Thread(root, first).ToJsonString(), "4f930186eb");
|
||||
var held = await Held(root);
|
||||
var handler = new RepliesJobHandler(_harness.Db, _harness.Remote, _harness.RemotePosts, _harness.Queue);
|
||||
|
||||
await handler.Handle(RepliesJobHandler.For(held, 1), token);
|
||||
Assert.Equal("4f930186eb", (await DB.Default.Find<ThreadCollection>().Match(c => c.URI == context).ExecuteFirstAsync(token)).ETag);
|
||||
// the same version: a 304, and nothing read
|
||||
_harness.Peer.ServeTagged(path, Thread(root, first, later).ToJsonString(), "4f930186eb");
|
||||
await handler.Handle(RepliesJobHandler.For(held, 1), token);
|
||||
Assert.Equal("4f930186eb", _harness.Peer.Requests.Last(r => r.Path == path).Headers["If-None-Match"]);
|
||||
Assert.Single(await Stored(first, later));
|
||||
// a new one
|
||||
_harness.Peer.ServeTagged(path, Thread(root, first, later).ToJsonString(), "5a0b");
|
||||
await handler.Handle(RepliesJobHandler.For(held, 1), token);
|
||||
Assert.Equal(2, (await Stored(first, later)).Count);
|
||||
Assert.Equal("5a0b", (await DB.Default.Find<ThreadCollection>().Match(c => c.URI == context).ExecuteFirstAsync(token)).ETag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_paged_contexts_etag_is_not_kept_since_its_first_page_hides_what_is_added_later()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var poster = new RemoteActor(_harness.Peer, "poster");
|
||||
var root = PublicNote(poster, "<p>a long thread</p>");
|
||||
var context = NewId(poster, "contexts");
|
||||
root["context"] = context;
|
||||
Served(root);
|
||||
var answer = Served(Reply(poster, "on the second page", root));
|
||||
_harness.Peer.ServeTagged(new Uri(context).AbsolutePath, new JsonObject
|
||||
{
|
||||
["id"] = context, ["type"] = "Collection",
|
||||
["first"] = new JsonObject { ["type"] = "CollectionPage", ["items"] = new JsonArray(IdOf(root)), ["next"] = context + "/2" }
|
||||
}.ToJsonString(), "W/\"first\"");
|
||||
Served(new JsonObject { ["id"] = context + "/2", ["type"] = "CollectionPage", ["items"] = new JsonArray(IdOf(answer)) });
|
||||
var held = await Held(root);
|
||||
|
||||
await new RepliesJobHandler(_harness.Db, _harness.Remote, _harness.RemotePosts, _harness.Queue).Handle(RepliesJobHandler.For(held, 1), token);
|
||||
Assert.Single(await Stored(answer));
|
||||
Assert.False(await DB.Default.Find<ThreadCollection>().Match(c => c.URI == context && c.ETag != null).ExecuteAnyAsync(token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_threads_context_collection_brings_every_reply_at_once()
|
||||
{
|
||||
|
||||
@@ -21,6 +21,7 @@ namespace PrivaPub.Tests.Support
|
||||
readonly ConcurrentDictionary<string, (string Text, string ContentType)> _documents = new();
|
||||
readonly ConcurrentDictionary<string, (int Status, TimeSpan Delay)> _answers = new();
|
||||
readonly ConcurrentDictionary<string, (byte[] Bytes, string ContentType)> _files = new();
|
||||
readonly ConcurrentDictionary<string, string> _etags = new();
|
||||
|
||||
public int Port { get; }
|
||||
public string A => $"http://127.0.0.1:{Port}";
|
||||
@@ -64,6 +65,15 @@ namespace PrivaPub.Tests.Support
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
if (peer._etags.TryGetValue(key, out var etag))
|
||||
{
|
||||
if (context.Request.Headers.IfNoneMatch.ToString() == etag)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status304NotModified;
|
||||
return;
|
||||
}
|
||||
context.Response.Headers.ETag = etag;
|
||||
}
|
||||
context.Response.ContentType = document.ContentType;
|
||||
await context.Response.WriteAsync(document.Text.Replace("{A}", peer.A).Replace("{B}", peer.B));
|
||||
});
|
||||
@@ -74,6 +84,13 @@ namespace PrivaPub.Tests.Support
|
||||
|
||||
public void Serve(string path, string json) => _documents[path] = (json, "application/activity+json");
|
||||
|
||||
// a document with an ETag: answered 304 to a request that names it in If-None-Match
|
||||
public void ServeTagged(string path, string json, string etag)
|
||||
{
|
||||
_documents[path] = (json, "application/activity+json");
|
||||
_etags[path] = etag;
|
||||
}
|
||||
|
||||
public void ServeText(string path, string text, string contentType) => _documents[path] = (text, contentType);
|
||||
|
||||
public void ServeFile(string path, byte[] bytes, string contentType) => _files[path] = (bytes, contentType);
|
||||
|
||||
Reference in new issue
Block a user