diff --git a/CLAUDE.md b/CLAUDE.md
index 65b5399..04cb983 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -561,6 +561,10 @@ tools/pasture/run.sh down # removes e
checks.
- **Pins (`scenarios/pins.sh`, needs mastodon):** a Mastodon account pins and unpins while alice follows it, alice pins
and unpins while it follows her, and a fresh account's earlier pin shows once PrivaPub resolves it. 8 checks.
+- **BookWyrm (0.9.3):** its image, migrated, on the shared Postgres (database `bookwyrm`) and Redis (databases 14
+ and 15), gunicorn and one Celery worker (federation runs there). It has no client API: bwuser (named
+ `bwuser@bookwyrm.test`, as its signup names users), a book and every status come from its Django shell
+ (`bookwyrm_shell`, `-v 0`). `scenarios/bookwyrm.sh`, 20 checks.
- **Ghost (6.67) with its ActivityPub service (1.2.14):** Ghost and the service on the shared MySQL (databases `ghost`
and `activitypub`, the service's migrations run first), routed by Caddy as Ghost's own proxy does. The owner comes
from Ghost's setup API (a password that repeats the site's name is refused), staff device verification is off, and
diff --git a/FEDERATION.md b/FEDERATION.md
index a69d5ad..496d25d 100644
--- a/FEDERATION.md
+++ b/FEDERATION.md
@@ -38,6 +38,7 @@ and every run starting clean, with signed fetches required (as privapub.thepra.d
- **WriteFreely 0.17.2**
- **Owncast 0.3.0**
- **Ghost 6.67** with its ActivityPub service 1.2.14
+- **BookWyrm 0.9.3**
- **Activity-Relay 2.0.9** and **aode-relay 0.3.129**, as relays PrivaPub reads from
- in the town only (a seeded community checked server by server): **Hollo 0.9.19**, **Iceshrimp.NET 2026.1.2-beta**,
**Pleroma 2.10.2**
@@ -330,7 +331,9 @@ Posts with a location (shown to nearby users of this server) never leave the ser
votes. All of them are always sent with their object embedded. Every boost and every favourite is an activity of its
own (`announce-{boost}`, `like-{favourite}`), so one given again after its `Undo` is new to the server it reaches.
- **Hashtags.** A post's `Hashtag` links go to `/tags/{tag}`, a public page of this server's public posts with that tag.
-- **Delivery.** Failed deliveries are retried with Mastodon's backoff (16 attempts). A host that keeps failing is paused,
+- **Delivery.** An activity goes once to each server, to its shared inbox when the server has one, whoever there it
+ follows, names or answers (a circle post excepted: each member's copy names that member). Failed deliveries are
+ retried with Mastodon's backoff (16 attempts). A host that keeps failing is paused,
starting at an hour and growing to a week. A server can also take a Follow (202) and drop it afterwards, as Pleroma
does when it cannot yet fetch our actor, so a follow request still unanswered is sent again, same activity, when the
persona follows once more, at most once an hour.
diff --git a/PrivaPub.Tests/Federation/SharedInboxTests.cs b/PrivaPub.Tests/Federation/SharedInboxTests.cs
new file mode 100644
index 0000000..5923697
--- /dev/null
+++ b/PrivaPub.Tests/Federation/SharedInboxTests.cs
@@ -0,0 +1,50 @@
+using PrivaPub.Domain.Statuses;
+using PrivaPub.Tests.Support;
+
+using System.Text.Json.Nodes;
+
+namespace PrivaPub.Tests.Federation
+{
+ // one delivery a server: a reply to a follower's post goes to its server's shared inbox once, not also to the
+ // follower's own inbox (BookWyrm kept two copies of a post that reached both at once)
+ [Trait("Category", "Integration")]
+ public sealed class SharedInboxTests : IAsyncLifetime
+ {
+ Harness _harness;
+
+ public async ValueTask InitializeAsync()
+ {
+ Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
+ _harness = await Harness.Start();
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ if (_harness != default)
+ await _harness.DisposeAsync();
+ }
+
+ [Fact]
+ public async Task A_reply_to_a_followers_post_reaches_its_server_once_through_the_shared_inbox()
+ {
+ var token = TestContext.Current.CancellationToken;
+ var (_, alice) = await _harness.Persona("alice");
+ var bob = new RemoteActor(_harness.Peer, "bob", sharedInbox: true);
+ await _harness.FollowedBy(alice, bob);
+ var path = $"/notes/{Guid.NewGuid():N}";
+ var noteId = new Uri(bob.Id).GetLeftPart(UriPartial.Authority) + path;
+ _harness.Peer.Serve(path, new JsonObject
+ {
+ ["id"] = noteId, ["type"] = "Note", ["attributedTo"] = bob.Id, ["content"] = "
a question
",
+ ["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"), ["published"] = DateTime.UtcNow.ToString("O")
+ }.ToJsonString());
+ var parent = await _harness.RemotePosts.StoreContext(noteId, 0, token);
+
+ var reply = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "an answer", InReplyTo = parent.ID }, token);
+
+ Assert.True(reply.Ok, reply.Error);
+ Assert.Single(await _harness.Outgoing(bob.SharedInbox), a => a["type"]!.GetValue() == "Create");
+ Assert.Empty(await _harness.Outgoing(bob.Id + "/inbox"));
+ }
+ }
+}
diff --git a/PrivaPub.Tests/Support/RemoteActor.cs b/PrivaPub.Tests/Support/RemoteActor.cs
index 609ea50..cfa5a93 100644
--- a/PrivaPub.Tests/Support/RemoteActor.cs
+++ b/PrivaPub.Tests/Support/RemoteActor.cs
@@ -13,12 +13,15 @@ namespace PrivaPub.Tests.Support
public sealed class RemoteActor
{
readonly RSA _key = RSA.Create(2048);
+ readonly bool _namesSharedInbox;
- public RemoteActor(Peer peer, string name, string origin = default, string type = "Person")
+ // sharedInbox: whether its document names its server's shared inbox (endpoints.sharedInbox), as Mastodon's do
+ public RemoteActor(Peer peer, string name, string origin = default, string type = "Person", bool sharedInbox = false)
{
Name = $"{name}{Guid.NewGuid():N}"[..20];
Id = $"{origin ?? peer.A}/users/{Name}";
Type = type;
+ _namesSharedInbox = sharedInbox;
peer.Serve($"/users/{Name}", Document().ToJsonString());
}
@@ -29,21 +32,27 @@ namespace PrivaPub.Tests.Support
public string KeyId => Id + "#main-key";
public string SharedInbox => Id.Split("/users/")[0] + "/inbox";
- public JsonObject Document() => new()
+ public JsonObject Document()
{
- ["id"] = Id,
- ["type"] = Type,
- ["preferredUsername"] = Name,
- ["inbox"] = Id + "/inbox",
- ["followers"] = Id + "/followers",
- ["featured"] = Id + "/featured",
- ["publicKey"] = new JsonObject
+ var document = new JsonObject
{
- ["id"] = KeyId,
- ["owner"] = Id,
- ["publicKeyPem"] = _key.ExportSubjectPublicKeyInfoPem()
- }
- };
+ ["id"] = Id,
+ ["type"] = Type,
+ ["preferredUsername"] = Name,
+ ["inbox"] = Id + "/inbox",
+ ["followers"] = Id + "/followers",
+ ["featured"] = Id + "/featured",
+ ["publicKey"] = new JsonObject
+ {
+ ["id"] = KeyId,
+ ["owner"] = Id,
+ ["publicKeyPem"] = _key.ExportSubjectPublicKeyInfoPem()
+ }
+ };
+ if (_namesSharedInbox)
+ document["endpoints"] = new JsonObject { ["sharedInbox"] = SharedInbox };
+ return document;
+ }
public HttpRequestMessage SignedGet(string path, string host = "privapub.test")
{
diff --git a/PrivaPub/Federation/Outbox/OutboxPublisher.cs b/PrivaPub/Federation/Outbox/OutboxPublisher.cs
index bc1af60..e95f8a6 100644
--- a/PrivaPub/Federation/Outbox/OutboxPublisher.cs
+++ b/PrivaPub/Federation/Outbox/OutboxPublisher.cs
@@ -58,15 +58,15 @@ namespace PrivaPub.Federation.Outbox
foreach (var uri in addressed)
{
var actor = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == uri).ExecuteFirstAsync(token);
- if (actor != default && !string.IsNullOrEmpty(actor.InboxURL))
- inboxes.Add(actor.InboxURL);
+ if (Preferred(actor) is { } inbox)
+ inboxes.Add(inbox);
}
if (post.Visibility != PostVisibility.Direct && !string.IsNullOrEmpty(post.InReplyToAccountId))
{
var parentAuthor = await _dbEntities.ForeignAvatars.MatchID(post.InReplyToAccountId).ExecuteFirstAsync(token);
- if (parentAuthor != default && !string.IsNullOrEmpty(parentAuthor.InboxURL) && !blockers.Contains(parentAuthor.ActorURI))
- inboxes.Add(parentAuthor.InboxURL);
+ if (Preferred(parentAuthor) is { } inbox && !blockers.Contains(parentAuthor.ActorURI))
+ inboxes.Add(inbox);
}
if (post.Visibility is PostVisibility.Public or PostVisibility.Unlisted && !string.IsNullOrEmpty(post.QuotedPostId))
@@ -75,13 +75,19 @@ namespace PrivaPub.Federation.Outbox
var quotedAuthor = quoted is { IsFederatedCopy: true }
? await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == quoted.ActorURI).ExecuteFirstAsync(token)
: default;
- if (!string.IsNullOrEmpty(quotedAuthor?.InboxURL))
- inboxes.Add(quotedAuthor.InboxURL);
+ if (Preferred(quotedAuthor) is { } inbox)
+ inboxes.Add(inbox);
}
return inboxes.Where(i => !string.IsNullOrEmpty(i)).Distinct(StringComparer.Ordinal).ToList();
}
+ // a server's shared inbox when it has one, as Mastodon delivers: one delivery a server, whoever on it is named, and
+ // none of the races a server runs into when the same activity reaches two of its inboxes at once (BookWyrm kept
+ // two copies of a post that named a follower)
+ static string Preferred(ForeignAvatar actor) =>
+ actor == default ? default : !string.IsNullOrEmpty(actor.SharedInboxURL) ? actor.SharedInboxURL : actor.InboxURL is { Length: > 0 } inbox ? inbox : default;
+
async Task> CircleMembers(string groupId, CancellationToken token) =>
(await CircleRecipients(groupId, token)).Select(r => r.InboxURL).Distinct(StringComparer.Ordinal).ToList();
diff --git a/docs/INTEROP.md b/docs/INTEROP.md
index 1d493e5..71b0925 100644
--- a/docs/INTEROP.md
+++ b/docs/INTEROP.md
@@ -753,6 +753,20 @@ PeerTube's own instance account announces each new video too, which we drop: nob
follow only PeerTube-like channels and accounts, never a persona. It checks the `Host` header against its own name,
without a port, before it gives out its OAuth client.
+### BookWyrm 0.9.3
+
+- Its statuses are `Review`, `Comment` and `Quotation` about a book (`inReplyToBook`) for other BookWyrms, and "pure"
+ `Article`s and `Note`s (the book named in the text) for everyone else, chosen by the recipient server's NodeInfo:
+ PrivaPub gets the pure ones.
+- It keeps nothing of others' but what concerns a book or one of its accounts (a reply to its statuses, a mention).
+- WebFinger finds a local user only under the username its signup gives it, `localname@domain`.
+- It took the same post twice when one copy reached its shared inbox and another the named account's inbox at once.
+ Since 2026-10-05 PrivaPub delivers once a server, to its shared inbox, as Mastodon does.
+- **Pasture evidence (2026-10-05, `tools/pasture/scenarios/bookwyrm.sh`):** 20 checks pass: follows both ways; a
+ review, a comment and a quotation reach alice; her like, boost and reply land there; her post naming bwuser lands, and
+ bwuser's like and reply reach her; a deletion; the unfollow; statistics. BookWyrm has no client API, so the scenario
+ acts in its Django shell, as its views do.
+
### Ghost 6.67 and its ActivityPub service 1.2.14
- A publication is one actor, `@index@` (`/.ghost/activitypub/users/index`), served by a separate service
diff --git a/tools/pasture/Caddyfile b/tools/pasture/Caddyfile
index 15453fe..0b90856 100644
--- a/tools/pasture/Caddyfile
+++ b/tools/pasture/Caddyfile
@@ -108,6 +108,11 @@ nodebb.test {
reverse_proxy pasture-nodebb:4567
}
+bookwyrm.test {
+ tls internal
+ reverse_proxy pasture-bookwyrm:8000
+}
+
ghost.test {
tls internal
handle /.ghost/activitypub/* {
diff --git a/tools/pasture/peers/bookwyrm.sh b/tools/pasture/peers/bookwyrm.sh
new file mode 100644
index 0000000..798dc24
--- /dev/null
+++ b/tools/pasture/peers/bookwyrm.sh
@@ -0,0 +1,47 @@
+# BookWyrm 0.9.3: reading logs and book reviews. Its statuses are Reviews, Comments and Quotations on a book, sent to
+# other BookWyrms as such and to everyone else as "pure" Notes and Articles. Its image on the shared Postgres (database
+# bookwyrm) and Redis (databases 14 and 15, activity and broker), gunicorn and one Celery worker (federation runs in
+# it), as bookwyrm.test; Python trusts the bundle the pasture gives requests. It has no client API: bwuser, a book and
+# the statuses are made in its Django shell (bookwyrm_shell), as its own views make them.
+BOOKWYRM_IMAGE=${BOOKWYRM_IMAGE:-ghcr.io/bookwyrm-social/bookwyrm:v0.9.3}
+BOOKWYRM_PASSWORD=Bookwyrm-Pasture-Pass-1
+. "$here/peers/shared.sh"
+
+bookwyrm_env() {
+ echo "-e DEBUG=false -e DOMAIN=bookwyrm.test -e EMAIL=admin@bookwyrm.test -e SECRET_KEY=pasture-bookwyrm-not-a-secret-0123456789 \
+ -e POSTGRES_HOST=postgres -e POSTGRES_USER=pasture -e POSTGRES_PASSWORD=pasture -e POSTGRES_DB=bookwyrm -e PGPORT=5432 \
+ -e REDIS_ACTIVITY_HOST=redis -e REDIS_ACTIVITY_PORT=6379 -e REDIS_ACTIVITY_DB_INDEX=14 \
+ -e REDIS_BROKER_HOST=redis -e REDIS_BROKER_PORT=6379 -e REDIS_BROKER_DB_INDEX=15 \
+ -e EMAIL_HOST=localhost -e EMAIL_PORT=25 -e EMAIL_HOST_USER=pasture -e EMAIL_HOST_PASSWORD=pasture -e EMAIL_USE_TLS=false -e ENABLE_THUMBNAIL_GENERATION=false -e ENABLE_PREVIEW_IMAGES=false \
+ -e REQUESTS_CA_BUNDLE=/pasture/ca/bundle.pem -e SSL_CERT_FILE=/pasture/ca/bundle.pem -e STATIC_ROOT=static/ -e MEDIA_ROOT=images/"
+}
+
+bookwyrm_up() {
+ shared_postgres_up
+ shared_redis_up
+ pg_db bookwyrm
+ podman run --rm --network $net $(bookwyrm_env) -v "$ca:/pasture/ca:z,ro" $BOOKWYRM_IMAGE python manage.py migrate --noinput >/dev/null 2>&1
+ podman run --rm --network $net $(bookwyrm_env) -v "$ca:/pasture/ca:z,ro" $BOOKWYRM_IMAGE python manage.py initdb >/dev/null 2>&1 || true
+ podman run -d --replace --name pasture-bookwyrm --label pasture=1 --network $net $(bookwyrm_env) -v "$ca:/pasture/ca:z,ro" \
+ $BOOKWYRM_IMAGE gunicorn bookwyrm.wsgi:application --bind 0.0.0.0:8000 >/dev/null
+ podman run -d --replace --name pasture-bookwyrm-celery --label pasture=1 --network $net $(bookwyrm_env) -v "$ca:/pasture/ca:z,ro" \
+ $BOOKWYRM_IMAGE celery -A celerywyrm worker --pool=threads --concurrency=4 -l info \
+ -Q high_priority,medium_priority,low_priority,streams,images,suggested_users,email,connectors,lists,inbox,imports,import_triggered,broadcast,misc >/dev/null
+ for _ in $(seq 1 60); do
+ site bookwyrm.test -s -o /dev/null -w '%{http_code}' https://bookwyrm.test:6443/nodeinfo/2.0 2>/dev/null | grep -q 200 && break
+ sleep 2
+ done
+ bookwyrm_shell "
+from bookwyrm import models
+if not models.User.objects.filter(localname='bwuser').exists():
+ # its signup names a local user localname@domain, which WebFinger looks for
+ models.User.objects.create_user('bwuser@bookwyrm.test', 'bwuser@bookwyrm.test', '$BOOKWYRM_PASSWORD', localname='bwuser', local=True)
+if not models.Edition.objects.filter(title='The Pasture Book').exists():
+ work = models.Work.objects.create(title='The Pasture Book')
+ models.Edition.objects.create(title='The Pasture Book', parent_work=work)
+" >/dev/null
+ echo "bookwyrm: https://bookwyrm.test:6443"
+}
+
+# bookwyrm_shell : runs in BookWyrm's Django shell, its output printed
+bookwyrm_shell() { podman exec pasture-bookwyrm python manage.py shell -v 0 -c "$1" 2>/dev/null; }
diff --git a/tools/pasture/scenarios/bookwyrm.sh b/tools/pasture/scenarios/bookwyrm.sh
new file mode 100644
index 0000000..1dc432c
--- /dev/null
+++ b/tools/pasture/scenarios/bookwyrm.sh
@@ -0,0 +1,84 @@
+# BookWyrm 0.9.3: follows both ways; a review, a comment and a quotation on a book reach alice as the "pure" posts
+# BookWyrm sends everyone but other BookWyrms; alice's reply, like and boost land there; her post naming bwuser lands
+# too (BookWyrm keeps nothing else of others'), and bwuser's like and reply reach her; a deletion; both unfollows;
+# statistics. Driven through BookWyrm's Django shell (peers/bookwyrm.sh), as its own
+# views make these.
+. "$here/peers/bookwyrm.sh"
+# bw : in BookWyrm's shell, with bw (bwuser), book and remote(uri) at hand; prints what the code prints
+bw() {
+ bookwyrm_shell "
+from bookwyrm import models, activitypub
+bw = models.User.objects.get(localname='bwuser')
+book = models.Edition.objects.get(title='The Pasture Book')
+def remote(uri, model=models.User):
+ return activitypub.resolve_remote_id(uri, model=model)
+$1"
+}
+p_home_has() { curl -s -H "$PH" "$P/api/v1/timelines/home?limit=40" | j "print(next(((s.get('reblog') or s)['id'] for s in d if '$1' in ((s.get('reblog') or s)['content'] or '') or '$1' in (((s.get('reblog') or s).get('privapub') or {}).get('title') or '')), ''))"; }
+p_status() { curl -s -H "$PH" "$P/api/v1/statuses/$1" | j "print(d.get('$2'))"; }
+
+echo "bookwyrm"
+[ "$(bw "print(bw.remote_id)")" = "https://bookwyrm.test/user/bwuser" ] && ok "BookWyrm's shell as bwuser" || { ko "BookWyrm's shell"; return 1; }
+PT=$(privapub_token alice_bw)
+PH="Authorization: Bearer $PT"
+[ -n "$PT" ] && ok "PrivaPub token for alice_bw" || { ko "PrivaPub token for alice_bw"; return 1; }
+alice_uri="$(curl -s -H "$PH" "$P/api/v1/accounts/verify_credentials" | j "print(d['url'])" | sed 's|/@|/peasants/|')"
+run=$(date +%s)
+
+echo " follows"
+bw "
+alice = remote('$alice_uri')
+models.UserFollowRequest.objects.get_or_create(user_subject=bw, user_object=alice)" >/dev/null
+until_true 45 '[ "$(bw "print(bw.following.filter(remote_id=\"$alice_uri\").exists())")" = "True" ]' \
+ && ok "bwuser follows alice (Accept arrived)" || ko "alice's Accept never reached BookWyrm"
+bw_on_p=$(curl -s -H "$PH" "$P/api/v2/search?q=bwuser@bookwyrm.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
+[ -n "$bw_on_p" ] && ok "PrivaPub resolves bwuser" || ko "PrivaPub cannot resolve bwuser"
+curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$bw_on_p/follow"
+until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/relationships?id[]=$bw_on_p" | j "print(d[0][\"following\"])")" = "True" ]' \
+ && ok "alice follows bwuser (Accept arrived)" || ko "BookWyrm's Accept never arrived"
+
+echo " reading"
+bw "models.Review.objects.create(user=bw, book=book, name='A pasture review $run', content='Four stars for the meadow.
', rating=4, privacy='public')" >/dev/null
+until_true 60 '[ -n "$(p_home_has "A pasture review $run")" ]' && ok "bwuser's review reaches alice" || ko "bwuser's review never reached alice"
+bw "models.Comment.objects.create(user=bw, book=book, content='A comment on the book $run
', privacy='public')" >/dev/null
+until_true 60 '[ -n "$(p_home_has "A comment on the book $run")" ]' && ok "bwuser's comment reaches alice" || ko "bwuser's comment never reached alice"
+comment_on_p=$(p_home_has "A comment on the book $run")
+comment_uri=$(curl -s -H "$PH" "$P/api/v1/statuses/$comment_on_p" | j "print(d['uri'])")
+bw "models.Quotation.objects.create(user=bw, book=book, quote='The grass is greener $run
', content='So true.
', privacy='public')" >/dev/null
+until_true 60 '[ -n "$(p_home_has "The grass is greener $run")" ]' && ok "bwuser's quotation reaches alice" || ko "bwuser's quotation never reached alice"
+
+echo " alice answers"
+curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$comment_on_p/favourite"
+until_true 45 '[ "$(bw "print(models.Favorite.objects.filter(status__remote_id=\"$comment_uri\", user__remote_id=\"$alice_uri\").exists())")" = "True" ]' \
+ && ok "alice's like lands on BookWyrm" || ko "alice's like never reached BookWyrm"
+curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$comment_on_p/reblog"
+until_true 45 '[ "$(bw "print(models.Boost.objects.filter(boosted_status__remote_id=\"$comment_uri\", user__remote_id=\"$alice_uri\").exists())")" = "True" ]' \
+ && ok "alice's boost lands on BookWyrm" || ko "alice's boost never reached BookWyrm"
+reply_uri=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d "status=@bwuser@bookwyrm.test a PrivaPub reply $run&in_reply_to_id=$comment_on_p&visibility=public" | j "print(d['uri'])")
+until_true 45 '[ "$(bw "print(models.Status.objects.filter(remote_id=\"$reply_uri\", reply_parent__remote_id=\"$comment_uri\").exists())")" = "True" ]' \
+ && ok "alice's reply threads under bwuser's comment" || ko "alice's reply never reached BookWyrm"
+
+echo " bwuser answers"
+# (BookWyrm keeps only what concerns books or its own accounts: a post naming bwuser)
+p_post=$(curl -s -X POST -H "$PH" $P/api/v1/statuses -d "status=@bwuser@bookwyrm.test a PrivaPub post for BookWyrm $run&visibility=public")
+p_post_id=$(echo "$p_post" | j "print(d['id'])"); p_uri=$(echo "$p_post" | j "print(d['uri'])")
+until_true 45 '[ "$(bw "print(models.Status.objects.filter(remote_id=\"$p_uri\").exists())")" = "True" ]' \
+ && ok "alice's post naming bwuser reaches BookWyrm" || ko "alice's post never reached BookWyrm"
+bw "models.Favorite.objects.create(user=bw, status=models.Status.objects.get(remote_id='$p_uri'))" >/dev/null
+until_true 45 '[ "$(p_status $p_post_id favourites_count)" = "1" ]' && ok "bwuser's like counts on PrivaPub" || ko "bwuser's like never counted"
+bw "models.Status.objects.create(user=bw, content='a BookWyrm reply $run
', reply_parent=models.Status.objects.get(remote_id='$p_uri'), privacy='public')" >/dev/null
+until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$p_post_id/context" | j "print(any(\"a BookWyrm reply $run\" in s[\"content\"] for s in d[\"descendants\"]))")" = "True" ]' \
+ && ok "bwuser's reply threads under alice's post" || ko "bwuser's reply missing on PrivaPub"
+
+echo " deletions"
+bw "models.Comment.objects.get(remote_id='$comment_uri').delete()" >/dev/null
+until_true 45 '[ "$(curl -s -o /dev/null -w "%{http_code}" -H "$PH" "$P/api/v1/statuses/$comment_on_p")" = "404" ]' \
+ && ok "bwuser's deletion reaches PrivaPub" || ko "the deleted comment still shows on PrivaPub"
+
+echo " unfollows"
+curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$bw_on_p/unfollow"
+until_true 45 '[ "$(bw "print(bw.followers.filter(remote_id=\"$alice_uri\").exists())")" = "False" ]' \
+ && ok "alice's unfollow reaches BookWyrm" || ko "BookWyrm still counts alice"
+
+echo " statistics"
+stats_check bookwyrm.test bookwyrm