Files
thepraandClaude Opus 5.5 a5d493a8c9 Pasture: Friendica joins, in the scenarios and the town
Friendica 2026.05 on the shared MySQL and Redis, with its worker daemon as a sidecar. friendica_settle repairs what
its install leaves: the system user has no name, so the system account that signs its fetches is never made; the web
container cannot see the sidecar's daemon, so a queued job waits for the five-minute cron unless the daemon is
declared running; its log needs a file and debugging on. Accounts are saved through its API with locked=0 (a number:
"true" reads as 0, unlocked), which makes them soapbox pages that take followers without following back, and each
one's outbox is read once: a Follow that reaches an account Friendica has not cached makes it fetch the account from
itself, signed, and checking that signature recursed for five minutes, holding PrivaPub's first follow past its timeout.

scenarios/friendica.sh passes its 25 checks from a clean install: follows and unfollows, posts (a titled one with its
title), comments, likes, Friendica's dislike as a downvote, boosts, edits (through its web editor: its Mastodon API
never federates one) and deletes, both ways.

The town gets a Friendica driver (HTTP Basic, its MySQL read through mysql_json, edits in the web editor, no bookmark
on a reply) and specs/friendica-pair.json, which passes its 275 checks. On the way:
- the checker knows Friendica's thread model: a non-public reply reaches an account only under posts it holds, and a
  Friendica account's non-public reply in a thread another server owns reaches nobody else there;
- the seeder answers a follow request the target still holds whatever the follower's server says: Friendica reports a
  follow of someone already following its account as made at once (and shows that persona's followers-only posts
  while a locked persona still holds the request);
- the selftest skips polls where a platform has none; the shared MySQL helpers move to peers/shared.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 10:14:02 +02:00

158 lines
6.3 KiB
Python

"""Each driver against its own server, before any federation is trusted: two accounts of the platform make, like,
boost, reply to, vote on, edit and delete objects, and the database view (`stored`) must agree with what was done and
with the API. A failing selftest means the driver is wrong, not the federation."""
import time
import traceback
from dialects import driver
from dialects.base import Account, PostSpec, Unsupported
import state
PASSWORD = "Town-Selftest-Pass-7!"
def wait(fn, tries=20, delay=1.0):
for _ in range(tries):
value = fn()
if value:
return value
time.sleep(delay)
return fn()
class Result:
def __init__(self, peer):
self.peer = peer
self.passed = 0
self.failed = 0
self.skipped = 0
def check(self, cond, what):
if cond:
self.passed += 1
print(f" ok {what}")
else:
self.failed += 1
print(f" FAIL {what}")
def skip(self, what):
self.skipped += 1
print(f" n/a {what}")
def accounts(peer):
root = "st_root" if peer == "privapub" else None
# Mastodon reserves names that contain its own, so the platform goes in as its two-letter code
import gen
short = gen.SHORT.get(peer, peer[:2])
return [Account(peer, f"st_a_{short}", PASSWORD, name="Selftest A", bio="selftest", root=root,
fields=[("site", "pasture")], avatar_seed=f"st-a-{peer}"),
Account(peer, f"st_b_{short}", PASSWORD, name="Selftest B", root=root)]
def run(peers):
total_failed = 0
for peer in peers:
print(peer)
r = Result(peer)
try:
if peer == "lemmy":
lemmy(r)
else:
microblog(peer, r)
except Exception as e:
r.check(False, f"selftest stopped: {e!r}")
traceback.print_exc()
print(f" {r.passed} passed, {r.failed} failed, {r.skipped} not applicable")
total_failed += r.failed
return 1 if total_failed else 0
def microblog(peer, r):
d = driver(peer)
a, b = state.provision(peer, accounts(peer))
r.check(a.token and b.token, "both accounts signed in")
try:
d.update_profile(a, a.account)
r.check(True, "profile updated (name, bio, fields, avatar)")
except Unsupported as e:
r.skip(str(e))
made = d.post(a, PostSpec(text=f"selftest post on {peer} (st1)", tags=["townselftest"]))
row = wait(lambda: d.stored([made.uri])[made.uri].exists and d.stored([made.uri])[made.uri])
r.check(row and row.exists, f"the post is in the database under its uri {made.uri}")
r.check(row and str(row.local_id) == str(made.local_id), f"database id {row and row.local_id} is the API id {made.local_id}")
r.check(row and row.visibility == "public", f"stored visibility public (got {row and row.visibility})")
d.like(b, made.uri)
r.check(wait(lambda: d.stored([made.uri])[made.uri].likes == 1), "a like counts once")
try:
d.boost(b, made.uri)
r.check(wait(lambda: d.stored([made.uri])[made.uri].boosts == 1), "a boost counts once")
except Unsupported as e:
r.skip(str(e))
reply = d.post(b, PostSpec(text=f"selftest reply on {peer} (st2)", reply_to_uri=made.uri))
rrow = wait(lambda: d.stored([reply.uri])[reply.uri].exists and d.stored([reply.uri])[reply.uri])
r.check(rrow and rrow.parent_uri == made.uri, f"the reply names its parent (got {rrow and rrow.parent_uri})")
r.check(wait(lambda: (d.stored([made.uri])[made.uri].replies or 0) >= 1), "the parent counts the reply")
if "poll" in d.caps:
poll = d.post(a, PostSpec(text=f"selftest poll on {peer} (st3)", poll={"options": ["tea", "coffee"], "expires_in": 86400}))
d.vote(b, poll.uri, [1])
r.check(wait(lambda: (d.stored([poll.uri])[poll.uri].votes or [None, None])[1] == 1),
f"a vote counts on the second option (got {d.stored([poll.uri])[poll.uri].votes})")
else:
r.skip(f"{peer} posts no polls")
private = d.post(a, PostSpec(text=f"selftest followers-only on {peer} (st4)", visibility="followers"))
prow = d.stored([private.uri])[private.uri]
r.check(prow.visibility == "followers", f"stored visibility followers (got {prow.visibility})")
seen = d.seen(b, [made.uri, private.uri])
r.check(seen[made.uri], "a non-follower sees the public post")
r.check(not seen[private.uri], "a non-follower does not see the followers-only post")
try:
d.edit(a, made.uri, PostSpec(text=f"selftest post on {peer}, edited (st1)"))
r.check(wait(lambda: d.stored([made.uri])[made.uri].edited), "the edit is recorded")
except Unsupported as e:
r.skip(str(e))
d.delete(b, reply.uri)
gone = wait(lambda: (lambda x: not x.exists or x.deleted)(d.stored([reply.uri])[reply.uri]))
r.check(gone, "a deleted reply is gone or tombstoned")
try:
d.react(b, made.uri, "🎉")
r.check(wait(lambda: (d.stored([made.uri])[made.uri].reactions or {}).get("🎉", 0) >= 1), "a reaction is stored")
except Unsupported as e:
r.skip(str(e))
def lemmy(r):
d = driver("lemmy")
a, b = state.provision("lemmy", accounts("lemmy"))
r.check(a.token and b.token, "both accounts signed in")
name = f"stc{int(time.time())}"
c = d.community(a, name, "Selftest community")
thread = d.post(a, PostSpec(text="selftest thread body (st1)", title="Selftest thread", group=c["id"]))
row = d.stored([thread.uri])[thread.uri]
r.check(row.exists and row.local_id == thread.local_id, f"the thread is in the database ({row.local_id})")
d.like(b, thread.uri)
r.check(wait(lambda: d.stored([thread.uri])[thread.uri].likes >= 2), "an upvote counts (with the author's own)")
comment = d.post(b, PostSpec(text="selftest comment (st2)", reply_to_uri=thread.uri))
crow = d.stored([comment.uri])[comment.uri]
r.check(crow.parent_uri == thread.uri, f"the comment names its thread (got {crow.parent_uri})")
reply = d.post(a, PostSpec(text="selftest comment reply (st3)", reply_to_uri=comment.uri))
rrow = d.stored([reply.uri])[reply.uri]
r.check(rrow.parent_uri == comment.uri, f"a nested comment names its parent comment (got {rrow.parent_uri})")
d.downvote(a, comment.uri)
r.check(wait(lambda: (d.stored([comment.uri])[comment.uri].reactions or {}).get("down") == 1), "a downvote counts")
d.edit(a, thread.uri, PostSpec(text="selftest thread body, edited (st1)"))
r.check(wait(lambda: d.stored([thread.uri])[thread.uri].edited), "the edit is recorded")
pm = d.post(a, PostSpec(text="selftest private message (st4)", visibility="direct",
mentions=[f"{b.account.username}@lemmy.test"]))
r.check(d.stored([pm.uri])[pm.uri].exists, "a private message is in the database")
d.delete(a, reply.uri)
r.check(wait(lambda: d.stored([reply.uri])[reply.uri].deleted), "a deleted comment is marked deleted")