One delivery a server; BookWyrm 0.9.3 in the pasture
An activity now goes once to each server, to its shared inbox when it has one, for the accounts a post names, answers or quotes as for its followers, as Mastodon delivers (a circle post excepted: each member's copy names that member). BookWyrm took the same post twice when one copy reached its shared inbox and another the named account's inbox at once. SharedInboxTests checks a reply to a follower's post goes once. BookWyrm joins the pasture (peers/bookwyrm.sh: its image on the shared Postgres and Redis, gunicorn and a Celery worker, its user, book and statuses made in its Django shell). scenarios/bookwyrm.sh: follows both ways, a review, a comment and a quotation reaching alice as BookWyrm's pure posts, her like, boost and reply landing there, her post naming bwuser and bwuser's like and reply, a deletion, the unfollow and statistics: 20 checks. GoToSocial (64), Mastodon (57) and Misskey (35) still pass. 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
12b51a211a
commit
53158b4cf4
9 files changed
+231
-9
No files matched your search
@@ -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
|
||||
|
||||
+4
-1
@@ -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.
|
||||
|
||||
@@ -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"] = "<p>a question</p>",
|
||||
["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<string>() == "Create");
|
||||
Assert.Empty(await _harness.Outgoing(bob.Id + "/inbox"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,7 +32,9 @@ 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()
|
||||
{
|
||||
var document = new JsonObject
|
||||
{
|
||||
["id"] = Id,
|
||||
["type"] = Type,
|
||||
@@ -44,6 +49,10 @@ namespace PrivaPub.Tests.Support
|
||||
["publicKeyPem"] = _key.ExportSubjectPublicKeyInfoPem()
|
||||
}
|
||||
};
|
||||
if (_namesSharedInbox)
|
||||
document["endpoints"] = new JsonObject { ["sharedInbox"] = SharedInbox };
|
||||
return document;
|
||||
}
|
||||
|
||||
public HttpRequestMessage SignedGet(string path, string host = "privapub.test")
|
||||
{
|
||||
|
||||
@@ -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<IReadOnlyList<string>> CircleMembers(string groupId, CancellationToken token) =>
|
||||
(await CircleRecipients(groupId, token)).Select(r => r.InboxURL).Distinct(StringComparer.Ordinal).ToList();
|
||||
|
||||
|
||||
@@ -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@<site>` (`/.ghost/activitypub/users/index`), served by a separate service
|
||||
|
||||
@@ -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/* {
|
||||
|
||||
@@ -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 <python>: 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; }
|
||||
@@ -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 <python>: 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='<p>Four stars for the meadow.</p>', 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='<p>A comment on the book $run</p>', 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='<p>The grass is greener $run</p>', content='<p>So true.</p>', 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='<p>a BookWyrm reply $run</p>', 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
|
||||
Reference in new issue
Block a user