Code Farm: per-plot auto-replant pause and crop-lock check; community sync and node work
DevPlace CI / test (push) Failing after 44m45s

Continuous Delivery could never be turned off once a crop was planted. Add a
one-shot per-plot "stop after harvest" flag (POST /game/plot/stop, Devii tool
game_plot_stop, game_plots.stop_after_harvest), cleared by the next plant,
autoreplant claim or Refactor. Autoreplant now also respects crop locks through
the shared _crop_lock predicate. Plan and measurements in happyplanting.md.

Also includes the pending community sync, node network and related work in the
tree; the full suite passed (4999) with all of it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 02:23:43 +02:00
co-authored by Claude Sonnet 5.5
parent ce899fc5e1
commit 02ab747cd0
203 changed files with 11624 additions and 301 deletions
+80
View File
@@ -0,0 +1,80 @@
# retoor <retoor@molodetz.nl>
import base64
from pathlib import Path
from devplacepy.config import ATTACHMENTS_DIR
from devplacepy.database import get_table
from tests.api.sync.conftest import PNG_1X1, attachment_record, seed_world, sync_ok
def local(origin, kind, origin_uid):
return get_table("sync_links").find_one(origin=origin, kind=kind, origin_uid=origin_uid)["local_uid"]
def test_an_attachment_is_stored_and_linked_to_its_target(client, admin, origin):
seed_world(client, admin, origin)
result = sync_ok(client, admin, "attachments", origin, [attachment_record()])["results"][0]
assert result["status"] == "created"
row = get_table("attachments").find_one(uid=result["local_uid"])
assert (row["target_type"], row["target_uid"]) == ("post", local(origin, "posts", "p1"))
assert row["user_uid"] == local(origin, "users", "u1") and row["mime_type"] == "image/png"
assert (Path(ATTACHMENTS_DIR) / row["directory"] / row["stored_name"]).read_bytes() == base64.b64decode(PNG_1X1)
assert row["created_at"].startswith("2025-01-05")
def test_replay_is_unchanged_and_a_content_change_is_refused(client, admin, origin):
seed_world(client, admin, origin)
sync_ok(client, admin, "attachments", origin, [attachment_record()])
assert sync_ok(client, admin, "attachments", origin, [attachment_record()])["results"][0]["status"] == "unchanged"
other = base64.b64encode(b"GIF89a" + b"x" * 40).decode()
changed = sync_ok(client, admin, "attachments", origin, [attachment_record(content=other, filename="b.gif", updated_at="2025-02-01T00:00:00Z")])["results"][0]
assert changed["status"] == "rejected" and changed["reason"] == "immutable"
def test_tombstone_soft_deletes_and_restores(client, admin, origin):
seed_world(client, admin, origin)
created = sync_ok(client, admin, "attachments", origin, [attachment_record()])["results"][0]
gone = attachment_record(updated_at="2025-02-01T00:00:00Z", deleted=True)
assert sync_ok(client, admin, "attachments", origin, [gone])["results"][0]["status"] == "updated"
assert get_table("attachments").find_one(uid=created["local_uid"])["deleted_by"] == "sync"
back = attachment_record(updated_at="2025-03-01T00:00:00Z")
sync_ok(client, admin, "attachments", origin, [back])
assert get_table("attachments").find_one(uid=created["local_uid"])["deleted_at"] is None
def test_validation_and_type_rules(client, admin, origin):
seed_world(client, admin, origin)
body = sync_ok(
client, admin, "attachments", origin,
[
attachment_record("a1", content="not base64!!"),
attachment_record("a2", content=""),
attachment_record("a3", filename="run.exe", content=base64.b64encode(b"MZ" + b"0" * 10).decode()),
attachment_record("a4", filename="../../etc/passwd.png"),
],
)
statuses = [item["status"] for item in body["results"]]
assert statuses[:3] == ["rejected", "rejected", "rejected"]
stored = get_table("attachments").find_one(uid=body["results"][3].get("local_uid") or "x")
assert statuses[3] == "created" and stored["original_filename"] == "passwd.png"
def test_an_oversize_file_is_refused(client, admin, origin):
from devplacepy.services.sync.spec import LIMITS
seed_world(client, admin, origin)
big = base64.b64encode(b"\x00" * (LIMITS.max_attachment_bytes + 1)).decode()
result = sync_ok(client, admin, "attachments", origin, [attachment_record(content=big)])["results"][0]
assert result["status"] == "rejected" and "exceeds" in result["errors"][0]
def test_an_unknown_target_waits_and_is_released_when_the_target_arrives(client, admin, origin):
from tests.api.sync.conftest import post_record, user_record
sync_ok(client, admin, "users", origin, [user_record("u1", "attuser1")])
waiting = sync_ok(client, admin, "attachments", origin, [attachment_record(target="p1")])["results"][0]
assert waiting["status"] == "deferred"
body = sync_ok(client, admin, "posts", origin, [post_record("p1", "u1")])
assert body["released"] == {"created": 1}
assert get_table("attachments").count(target_uid=local(origin, "posts", "p1")) == 1
+2 -1
View File
@@ -9,7 +9,7 @@ def seed_thread(client, admin, origin):
sync_ok(client, admin, "posts", origin, [post_record("p1", "u1"), post_record("p2", "u1")])
def test_unknown_references_are_rejected_with_specific_reasons(client, admin, origin):
def test_unknown_references_are_deferred_with_specific_reasons(client, admin, origin):
seed_thread(client, admin, origin)
body = sync_ok(
client, admin, "comments", origin,
@@ -20,6 +20,7 @@ def test_unknown_references_are_rejected_with_specific_reasons(client, admin, or
],
)
assert [r["reason"] for r in body["results"]] == ["unknown_author", "unknown_post", "unknown_parent"]
assert [r["status"] for r in body["results"]] == ["deferred", "deferred", "deferred"]
def test_create_with_nested_reply_in_one_batch(client, admin, origin):
+93
View File
@@ -96,3 +96,96 @@ def comment_record(uid="c1", post="p1", author="u1", **extra) -> dict:
"created_at": "2025-01-03T00:00:00Z",
**extra,
}
def project_record(uid="pr1", author="u1", **extra) -> dict:
return {
"origin_uid": uid,
"author_uid": author,
"title": "Synced project",
"description": "A project that came in from another community",
"created_at": "2025-01-02T00:00:00Z",
**extra,
}
def poll_record(uid="po1", post="p1", author="u1", **extra) -> dict:
return {
"origin_uid": uid,
"post_uid": post,
"author_uid": author,
"question": "Tabs or spaces?",
"options": [{"origin_uid": "a", "label": "Tabs"}, {"origin_uid": "b", "label": "Spaces"}],
"created_at": "2025-01-03T00:00:00Z",
**extra,
}
def vote_record(uid="v1", voter="u2", target="p1", kind="post", value=1, **extra) -> dict:
return {
"origin_uid": uid,
"voter_uid": voter,
"target_kind": kind,
"target_uid": target,
"value": value,
"created_at": "2025-01-04T00:00:00Z",
**extra,
}
def reaction_record(uid="r1", author="u2", target="p1", kind="post", emoji="🔥", **extra) -> dict:
return {
"origin_uid": uid,
"author_uid": author,
"target_kind": kind,
"target_uid": target,
"emoji": emoji,
"created_at": "2025-01-04T00:00:00Z",
**extra,
}
def follow_record(uid="f1", follower="u2", following="u1", **extra) -> dict:
return {
"origin_uid": uid,
"follower_uid": follower,
"following_uid": following,
"created_at": "2025-01-04T00:00:00Z",
**extra,
}
def poll_vote_record(uid="pv1", voter="u2", poll="po1", option="a", **extra) -> dict:
return {
"origin_uid": uid,
"voter_uid": voter,
"poll_uid": poll,
"option_uid": option,
"created_at": "2025-01-05T00:00:00Z",
**extra,
}
PNG_1X1 = (
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
)
def attachment_record(uid="at1", author="u1", target="p1", kind="post", filename="pixel.png", content=PNG_1X1, **extra) -> dict:
return {
"origin_uid": uid,
"author_uid": author,
"target_kind": kind,
"target_uid": target,
"filename": filename,
"content_b64": content,
"created_at": "2025-01-05T00:00:00Z",
**extra,
}
def seed_world(client, admin, origin):
sync_ok(client, admin, "users", origin, [user_record("u1", unique("wu1")), user_record("u2", unique("wu2")), user_record("u3", unique("wu3"))])
sync_ok(client, admin, "projects", origin, [project_record("pr1", "u1")])
sync_ok(client, admin, "posts", origin, [post_record("p1", "u1", project_uid="pr1")])
sync_ok(client, admin, "comments", origin, [comment_record("c1", "p1", "u2")])
+109
View File
@@ -0,0 +1,109 @@
# retoor <retoor@molodetz.nl>
from devplacepy.database import get_table
from tests.api.sync.conftest import comment_record, post_record, sync_ok, unique, user_record
def listing(client, admin, origin, **params):
return client.get("/sync/deletions", params={"origin": origin, **params}, headers=admin["headers"])
def ack(client, admin, origin, items):
return client.post("/sync/deletions/ack", json={"origin": origin, "items": items}, headers=admin["headers"])
def seed(client, admin, origin):
sync_ok(client, admin, "users", origin, [user_record("u1", unique("del"))])
sync_ok(client, admin, "posts", origin, [post_record("p1", "u1"), post_record("p2", "u1"), post_record("p3", "u1")])
sync_ok(client, admin, "comments", origin, [comment_record("c1", "p1", "u1")])
def local(origin, kind, origin_uid):
return get_table("sync_links").find_one(origin=origin, kind=kind, origin_uid=origin_uid)["local_uid"]
def test_authz(client, admin):
from tests.api.sync.conftest import make_account
assert client.get("/sync/deletions", params={"origin": "a.b"}).status_code == 401
assert listing(client, make_account("Member"), "a.b").status_code == 403
assert client.get("/sync/deletions", headers=admin["headers"]).status_code == 422
def test_nothing_is_listed_for_a_clean_origin_and_sync_own_deletions_never_are(client, admin, origin):
seed(client, admin, origin)
assert listing(client, admin, origin).json()["items"] == []
sync_ok(client, admin, "posts", origin, [post_record("p1", "u1", updated_at="2025-05-01T00:00:00Z", deleted=True)])
assert listing(client, admin, origin).json()["items"] == []
def test_a_member_deletion_a_system_deletion_and_a_purge_are_told_apart(client, admin, origin):
seed(client, admin, origin)
get_table("posts").update({"uid": local(origin, "posts", "p1"), "deleted_at": "2026-01-01T00:00:00+00:00", "deleted_by": "someone"}, ["uid"])
get_table("posts").update({"uid": local(origin, "posts", "p2"), "deleted_at": "2026-01-02T00:00:00+00:00", "deleted_by": "system"}, ["uid"])
purged_uid = local(origin, "posts", "p3")
get_table("posts").delete(uid=purged_uid)
body = listing(client, admin, origin).json()
found = {(item["kind"], item["origin_uid"]): item["by"] for item in body["items"]}
assert found == {("posts", "p1"): "member", ("posts", "p2"): "system", ("posts", "p3"): "purged"}
assert body["count"] == 3 and body["has_more"] is False
def test_paging_with_a_cursor_covers_every_entry_once(client, admin, origin):
seed(client, admin, origin)
for origin_uid in ("p1", "p2", "p3"):
get_table("posts").update({"uid": local(origin, "posts", origin_uid), "deleted_at": "2026-01-01T00:00:00+00:00", "deleted_by": "m"}, ["uid"])
get_table("comments").update({"uid": local(origin, "comments", "c1"), "deleted_at": "2026-01-01T00:00:00+00:00", "deleted_by": "m"}, ["uid"])
seen, cursor = [], ""
while True:
body = listing(client, admin, origin, limit=2, cursor=cursor).json()
seen += [(item["kind"], item["origin_uid"]) for item in body["items"]]
cursor = body["next_cursor"]
if not body["has_more"]:
break
assert sorted(seen) == [("comments", "c1"), ("posts", "p1"), ("posts", "p2"), ("posts", "p3")]
assert listing(client, admin, origin, cursor="zz").status_code == 400
def test_other_origins_are_never_listed(client, admin, origin):
seed(client, admin, origin)
get_table("posts").update({"uid": local(origin, "posts", "p1"), "deleted_at": "2026-01-01T00:00:00+00:00", "deleted_by": "m"}, ["uid"])
assert listing(client, admin, f"{unique('x')}.example.org").json()["items"] == []
def test_acknowledging_stops_the_listing_and_quiets_the_resend(client, admin, origin):
seed(client, admin, origin)
purged = local(origin, "posts", "p3")
get_table("posts").delete(uid=purged)
resend = sync_ok(client, admin, "posts", origin, [post_record("p3", "u1", updated_at="2025-09-01T00:00:00Z")])["results"][0]
assert resend["status"] == "rejected" and resend["reason"] == "purged_locally"
response = ack(client, admin, origin, [{"kind": "posts", "origin_uid": "p3"}])
assert response.status_code == 200 and response.json()["acknowledged"] == 1
assert listing(client, admin, origin).json()["items"] == []
again = sync_ok(client, admin, "posts", origin, [post_record("p3", "u1", updated_at="2025-10-01T00:00:00Z")])["results"][0]
assert again["status"] == "unchanged" and again["reason"] == "acknowledged_deleted"
assert ack(client, admin, origin, [{"kind": "posts", "origin_uid": "p3"}]).json()["acknowledged"] == 0
def test_a_locally_deleted_row_after_ack_answers_acknowledged(client, admin, origin):
seed(client, admin, origin)
get_table("posts").update({"uid": local(origin, "posts", "p1"), "deleted_at": "2026-01-01T00:00:00+00:00", "deleted_by": "m"}, ["uid"])
ack(client, admin, origin, [{"kind": "posts", "origin_uid": "p1"}])
result = sync_ok(client, admin, "posts", origin, [post_record("p1", "u1", content="edited after local delete", updated_at="2025-09-01T00:00:00Z")])["results"][0]
assert result["status"] == "unchanged" and result["reason"] == "acknowledged_deleted"
def test_ack_validation_and_audit(client, admin, origin):
assert ack(client, admin, origin, []).status_code == 422
assert ack(client, admin, origin, [{"kind": "gists", "origin_uid": "x"}]).status_code == 422
assert ack(client, admin, origin, [{"kind": "posts", "origin_uid": "bad id"}]).status_code == 422
assert ack(client, admin, origin, [{"kind": "posts", "origin_uid": "unknown-one"}]).json()["acknowledged"] == 0
rows = list(get_table("audit_log").find(event_key="sync.deletions.ack", actor_uid=admin["uid"]))
assert rows
def test_a_purged_user_is_listed(client, admin, origin):
seed(client, admin, origin)
get_table("users").delete(uid=local(origin, "users", "u1"))
items = listing(client, admin, origin).json()["items"]
assert ("users", "u1", "purged") in {(item["kind"], item["origin_uid"], item["by"]) for item in items}
+203
View File
@@ -0,0 +1,203 @@
# retoor <retoor@molodetz.nl>
from datetime import datetime, timedelta, timezone
import pytest
from devplacepy.database import get_table, set_setting
from devplacepy.utils import generate_uid, register_account
from tests.api.sync.conftest import (
comment_record,
post_record,
sync,
sync_ok,
unique,
user_record,
)
def export(client, admin, kind, **params):
return client.get(f"/sync/export/{kind}", params=params, headers=admin["headers"])
def pull_all(client, admin, kind, since=None):
records, cursor, pages = [], "", 0
while True:
params = {"cursor": cursor, "limit": 50}
if since:
params["since"] = since
response = export(client, admin, kind, **params)
assert response.status_code == 200, response.text
body = response.json()
records.extend(body["records"])
pages += 1
cursor = body["next_cursor"]
if not body["has_more"]:
return records, pages
def native_user(prefix="expu"):
name = unique(prefix)
uid, _, _ = register_account(name, f"{name}@t.dev", "secret123", age_band="adult", accepted_terms=True)
return uid, name
def native_post(user_uid, content="native post content here", stamp=None, **extra):
stamp = stamp or datetime.now(timezone.utc).isoformat()
uid = generate_uid()
get_table("posts").insert(
{
"uid": uid, "user_uid": user_uid, "slug": f"s-{uid[-12:]}", "title": "T", "content": content,
"topic": "random", "project_uid": None, "image": None, "stars": 0, "created_at": stamp,
"updated_at": stamp, "deleted_at": None, "deleted_by": None, **extra,
}
)
return uid
def test_authz_and_kill_switch(client, admin):
assert client.get("/sync/export/posts").status_code == 401
member = {"headers": {**admin["headers"]}}
from tests.api.sync.conftest import make_account
plain = make_account("Member")
assert export(client, plain, "posts").status_code == 403
set_setting("sync_enabled", "0")
try:
assert export(client, admin, "posts").status_code == 503
finally:
set_setting("sync_enabled", "1")
assert member
def test_unknown_and_private_kinds_are_not_exportable(client, admin):
for kind in ("votes", "reactions", "poll_votes", "nope"):
response = export(client, admin, kind)
assert response.status_code == 404 and response.json()["error"]["reason"] == "not_exportable"
def test_a_post_exports_in_the_inbound_shape_and_no_private_user_field_leaks(client, admin):
uid, name = native_user()
post = native_post(uid, "exported post content here")
users, _ = pull_all(client, admin, "users")
row = next(item for item in users if item["origin_uid"] == uid)
assert row["username"] == name and row["deleted"] is False
assert not set(row) & {"email", "password_hash", "api_key", "role", "xp", "level"}
posts, _ = pull_all(client, admin, "posts")
found = next(item for item in posts if item["origin_uid"] == post)
assert found["author_uid"] == uid and found["content"] == "exported post content here"
from devplacepy.services.sync.records import PostRecord
assert PostRecord.model_validate(found).origin_uid == post
def test_a_soft_deleted_post_is_a_tombstone_and_only_after_the_since_cursor(client, admin):
uid, _ = native_user()
post = native_post(uid)
before = datetime.now(timezone.utc).isoformat()
get_table("posts").update({"uid": post, "deleted_at": datetime.now(timezone.utc).isoformat(), "deleted_by": uid}, ["uid"])
posts, _ = pull_all(client, admin, "posts", since=before)
mine = [item for item in posts if item["origin_uid"] == post]
assert len(mine) == 1 and mine[0]["deleted"] is True
def test_pagination_is_gapless_and_repeat_free(client, admin):
uid, _ = native_user()
base = datetime.now(timezone.utc) - timedelta(days=1)
made = {native_post(uid, f"paged post number {index}", (base + timedelta(seconds=index // 3)).isoformat()) for index in range(120)}
records, pages = pull_all(client, admin, "posts")
ids = [item["origin_uid"] for item in records]
assert len(ids) == len(set(ids)) and made <= set(ids) and pages >= 3
def test_rows_that_arrived_by_sync_are_never_echoed(client, admin, origin):
sync_ok(client, admin, "users", origin, [user_record("u1", unique("echo"))])
sync_ok(client, admin, "posts", origin, [post_record("p1", "u1", content="synced in, must not come back out")])
sync_ok(client, admin, "comments", origin, [comment_record("c1", "p1", "u1")])
posts, _ = pull_all(client, admin, "posts")
assert not any(item["content"] == "synced in, must not come back out" for item in posts)
local_post = get_table("sync_links").find_one(origin=origin, kind="posts", origin_uid="p1")["local_uid"]
comments, _ = pull_all(client, admin, "comments")
assert not any(item["post_uid"] == local_post for item in comments)
def test_private_projects_are_left_out_and_post_links_to_them_are_cut(client, admin):
uid, _ = native_user()
public, private = generate_uid(), generate_uid()
now = datetime.now(timezone.utc).isoformat()
for project, is_private in ((public, 0), (private, 1)):
get_table("projects").insert(
{"uid": project, "user_uid": uid, "slug": f"p-{project[-10:]}", "title": "P", "description": "d", "project_type": "software",
"status": "x", "platforms": "", "website_url": "", "repo_url": "", "is_private": is_private, "read_only": 0, "stars": 0,
"created_at": now, "updated_at": now, "deleted_at": None, "deleted_by": None}
)
linked = native_post(uid, "post that points at a private project", project_uid=private)
open_post = native_post(uid, "post that points at a public project", project_uid=public)
projects, _ = pull_all(client, admin, "projects")
ids = {item["origin_uid"] for item in projects}
assert public in ids and private not in ids
posts, _ = pull_all(client, admin, "posts")
by_id = {item["origin_uid"]: item for item in posts}
assert by_id[linked]["project_uid"] is None and by_id[open_post]["project_uid"] == public
def test_polls_and_follows_and_attachments_export(client, admin):
alice, _ = native_user()
bob, _ = native_user()
post = native_post(alice, "post that carries a poll for export")
now = datetime.now(timezone.utc).isoformat()
poll = generate_uid()
get_table("polls").insert({"uid": poll, "post_uid": post, "user_uid": alice, "question": "Q?", "created_at": now, "deleted_at": None, "deleted_by": None})
first, second = generate_uid(), generate_uid()
get_table("poll_options").insert_many([
{"uid": first, "poll_uid": poll, "label": "yes", "position": 0, "deleted_at": None, "deleted_by": None},
{"uid": second, "poll_uid": poll, "label": "no", "position": 1, "deleted_at": None, "deleted_by": None},
])
follow = generate_uid()
get_table("follows").insert({"uid": follow, "follower_uid": bob, "following_uid": alice, "created_at": now, "deleted_at": None, "deleted_by": None})
attachment = generate_uid()
get_table("attachments").insert({"uid": attachment, "target_type": "post", "target_uid": post, "user_uid": alice, "original_filename": "a.png",
"stored_name": f"{attachment}.png", "directory": "ab/cd", "file_size": 10, "mime_type": "image/png", "created_at": now, "deleted_at": None})
polls, _ = pull_all(client, admin, "polls")
exported = next(item for item in polls if item["origin_uid"] == poll)
assert [option["label"] for option in exported["options"]] == ["yes", "no"]
from devplacepy.services.sync.records import PollRecord
assert PollRecord.model_validate(exported).question == "Q?"
follows, _ = pull_all(client, admin, "follows")
assert any(item["origin_uid"] == follow and item["follower_uid"] == bob for item in follows)
attachments, _ = pull_all(client, admin, "attachments")
found = next(item for item in attachments if item["origin_uid"] == attachment)
assert found["url"] == f"/static/uploads/attachments/ab/cd/{attachment}.png" and "content_b64" not in found
def test_bad_cursor_since_and_limit(client, admin):
assert export(client, admin, "posts", cursor="!!!").status_code == 400
assert export(client, admin, "posts", since="yesterday-ish").status_code == 400
body = export(client, admin, "posts", limit=1000).json()
assert body["count"] <= 50
def test_the_changed_expression_uses_an_index(local_db):
from devplacepy.database import db
plan = " ".join(
str(row)
for row in db.query(
"EXPLAIN QUERY PLAN SELECT * FROM posts t WHERE COALESCE(deleted_at, updated_at, created_at) > '2020' "
"ORDER BY COALESCE(deleted_at, updated_at, created_at), uid LIMIT 51"
)
)
assert "idx_posts_changed" in plan and "TEMP B-TREE" not in plan
def test_every_export_is_audited(client, admin):
pull_all(client, admin, "follows")
rows = list(get_table("audit_log").find(event_key="sync.export", actor_uid=admin["uid"]))
assert rows
@pytest.mark.parametrize("kind", ["users", "projects", "posts", "comments", "polls", "follows", "attachments"])
def test_every_exportable_kind_answers(client, admin, kind):
body = export(client, admin, kind).json()
assert body["kind"] == kind and isinstance(body["records"], list)
+31
View File
@@ -0,0 +1,31 @@
# retoor <retoor@molodetz.nl>
from devplacepy.database import get_table
from tests.api.sync.conftest import follow_record, seed_world, sync_ok
def local(origin, uid):
return get_table("sync_links").find_one(origin=origin, kind="users", origin_uid=uid)["local_uid"]
def test_follow_unfollow_refollow_keeps_one_row_and_has_no_side_effects(client, admin, origin):
seed_world(client, admin, origin)
notifications = get_table("notifications").count()
xp = get_table("users").find_one(uid=local(origin, "u1"))["xp"]
created = sync_ok(client, admin, "follows", origin, [follow_record("f1")])["results"][0]
assert created["status"] == "created"
sync_ok(client, admin, "follows", origin, [follow_record("f1", updated_at="2025-02-01T00:00:00Z", deleted=True)])
assert get_table("follows").find_one(uid=created["local_uid"])["deleted_by"] == "sync"
sync_ok(client, admin, "follows", origin, [follow_record("f1", updated_at="2025-03-01T00:00:00Z")])
assert get_table("follows").count(follower_uid=local(origin, "u2"), following_uid=local(origin, "u1")) == 1
assert get_table("follows").find_one(uid=created["local_uid"])["deleted_at"] is None
assert get_table("notifications").count() == notifications
assert get_table("users").find_one(uid=local(origin, "u1"))["xp"] == xp
def test_self_follow_and_unknown_users(client, admin, origin):
seed_world(client, admin, origin)
body = sync_ok(client, admin, "follows", origin, [follow_record("f1", "u1", "u1"), follow_record("f2", "ghost", "u1"), follow_record("f3", "u2", "ghost")])
assert [(item["status"], item["reason"]) for item in body["results"]] == [
("rejected", "self_follow"), ("deferred", "unknown_author"), ("deferred", "unknown_author"),
]
+120
View File
@@ -0,0 +1,120 @@
# retoor <retoor@molodetz.nl>
import dataclasses
import random
from devplacepy.database import get_table
from devplacepy.services.sync import processors
from devplacepy.services.sync.pending import PendingQueue
from devplacepy.services.sync.spec import LIMITS
from tests.api.sync.conftest import comment_record, post_record, sync_ok, unique, user_record
def link_count(origin, kind):
return get_table("sync_links").count(origin=origin, kind=kind)
def test_a_chain_of_waiting_records_is_released_by_the_first_dependency(client, admin, origin):
reply = sync_ok(client, admin, "comments", origin, [comment_record("c2", "p1", "u1", parent_uid="c1")])["results"][0]
parent = sync_ok(client, admin, "comments", origin, [comment_record("c1", "p1", "u1")])["results"][0]
post = sync_ok(client, admin, "posts", origin, [post_record("p1", "u1")])["results"][0]
assert (reply["status"], parent["status"], post["status"]) == ("deferred", "deferred", "deferred")
assert get_table("sync_pending").count(origin=origin) == 3
body = sync_ok(client, admin, "users", origin, [user_record("u1", unique("chain"))])
assert body["results"][0]["status"] == "created"
assert body["released"] == {"created": 3}
assert get_table("sync_pending").count(origin=origin) == 0
assert [link_count(origin, kind) for kind in ("users", "posts", "comments")] == [1, 1, 2]
comments = {row["origin_uid"]: row for row in get_table("sync_links").find(origin=origin, kind="comments")}
reply_row = get_table("comments").find_one(uid=comments["c2"]["local_uid"])
assert reply_row["parent_uid"] == comments["c1"]["local_uid"]
def test_any_arrival_order_ends_in_the_same_final_state(client, admin, origin):
records = [
("users", user_record("u1", unique("order1"))),
("users", user_record("u2", unique("order2"))),
("posts", post_record("p1", "u1")),
*[("comments", comment_record(f"c{index}", "p1", "u2")) for index in range(15)],
]
random.Random(7).shuffle(records)
for kind, record in records:
sync_ok(client, admin, kind, origin, [record])
assert [link_count(origin, kind) for kind in ("users", "posts", "comments")] == [2, 1, 15]
assert get_table("sync_pending").count(origin=origin) == 0
def test_a_resend_replaces_the_waiting_row_instead_of_stacking(client, admin, origin):
for _ in range(3):
sync_ok(client, admin, "posts", origin, [post_record("p1", "ghost")])
assert get_table("sync_pending").count(origin=origin) == 1
def test_a_resend_carries_the_newest_content(client, admin, origin):
sync_ok(client, admin, "posts", origin, [post_record("p1", "u1", content="the first version of it")])
sync_ok(client, admin, "posts", origin, [post_record("p1", "u1", content="the second version of it", updated_at="2025-02-01T00:00:00Z")])
sync_ok(client, admin, "users", origin, [user_record("u1", unique("newest"))])
link = get_table("sync_links").find_one(origin=origin, kind="posts", origin_uid="p1")
assert get_table("posts").find_one(uid=link["local_uid"])["content"] == "the second version of it"
def test_the_waiting_list_is_capped_per_origin_and_says_so(client, admin, origin, monkeypatch):
monkeypatch.setattr(processors, "LIMITS", dataclasses.replace(LIMITS, pending_max_per_origin=2))
body = sync_ok(client, admin, "posts", origin, [post_record(f"p{index}", "ghost") for index in range(4)])
assert [item["status"] for item in body["results"]] == ["deferred", "deferred", "rejected", "rejected"]
assert "waiting list" in body["results"][2]["errors"][-1]
assert get_table("sync_pending").count(origin=origin) == 2
def test_the_cap_is_per_origin(client, admin, origin, monkeypatch):
monkeypatch.setattr(processors, "LIMITS", dataclasses.replace(LIMITS, pending_max_per_origin=1))
other = f"{unique('o')}.example.org"
sync_ok(client, admin, "posts", origin, [post_record("p1", "ghost")])
assert sync_ok(client, admin, "posts", other, [post_record("p1", "ghost")])["results"][0]["status"] == "deferred"
def test_a_dry_run_never_queues(client, admin, origin):
body = sync_ok(client, admin, "posts", origin, [post_record("p1", "ghost")], dry_run=True)
assert body["results"][0]["status"] == "rejected" and body["released"] == {}
assert get_table("sync_pending").count(origin=origin) == 0
def test_a_tombstone_for_an_unknown_record_is_not_queued(client, admin, origin):
body = sync_ok(client, admin, "comments", origin, [comment_record("c1", "nopost", "nobody", deleted=True)])
assert body["results"][0]["status"] == "unchanged" and body["results"][0]["reason"] == "tombstone_for_unknown"
assert get_table("sync_pending").count(origin=origin) == 0
def test_an_oversize_record_is_not_queued(client, admin, origin, monkeypatch):
monkeypatch.setattr(processors, "LIMITS", dataclasses.replace(LIMITS, pending_max_record_bytes=100))
result = sync_ok(client, admin, "posts", origin, [post_record("p1", "ghost")])["results"][0]
assert result["status"] == "rejected" and "too large" in result["errors"][-1]
def test_expired_and_exhausted_rows_are_dropped_hard(client, admin, origin):
sync_ok(client, admin, "posts", origin, [post_record("p1", "ghost"), post_record("p2", "ghost")])
rows = list(get_table("sync_pending").find(origin=origin))
get_table("sync_pending").update({"uid": rows[0]["uid"], "expires_at": "2000-01-01T00:00:00+00:00"}, ["uid"])
get_table("sync_pending").update({"uid": rows[1]["uid"], "attempts": LIMITS.pending_max_attempts}, ["uid"])
assert PendingQueue().sweep() >= 2
assert get_table("sync_pending").count(origin=origin) == 0
def test_the_release_budget_bounds_the_work_per_call(client, admin, origin):
for index in range(5):
sync_ok(client, admin, "posts", origin, [post_record(f"p{index}", "u1")])
from devplacepy.database.sync import claim_link
author = get_table("users").insert({"uid": "budget-author", "username": unique("budget"), "created_at": "2025-01-01T00:00:00+00:00"})
claim_link(origin, "users", "u1", "budget-author", "x", "2025-01-01T00:00:00+00:00")
released = PendingQueue().release(origin, cap=2)
assert sum(released.values()) == 2 and author
assert get_table("sync_pending").count(origin=origin) == 3
def test_pending_rows_of_one_origin_are_never_released_by_another(client, admin, origin):
other = f"{unique('o')}.example.org"
sync_ok(client, admin, "posts", origin, [post_record("p1", "u1")])
sync_ok(client, admin, "users", other, [user_record("u1", unique("isolated"))])
assert get_table("sync_pending").count(origin=origin) == 1
assert link_count(origin, "posts") == 0
+41
View File
@@ -0,0 +1,41 @@
# retoor <retoor@molodetz.nl>
from devplacepy.database import get_table
from tests.api.sync.conftest import poll_record, poll_vote_record, seed_world, sync_ok
def setup(client, admin, origin):
seed_world(client, admin, origin)
return sync_ok(client, admin, "polls", origin, [poll_record()])["results"][0]["local_uid"]
def test_a_ballot_changes_option_without_duplicating(client, admin, origin):
poll = setup(client, admin, origin)
first = sync_ok(client, admin, "poll_votes", origin, [poll_vote_record("pv1", option="a")])["results"][0]
assert first["status"] == "created"
sync_ok(client, admin, "poll_votes", origin, [poll_vote_record("pv1", option="b", updated_at="2025-02-01T00:00:00Z")])
rows = list(get_table("poll_votes").find(poll_uid=poll))
assert len(rows) == 1
option = get_table("poll_options").find_one(uid=rows[0]["option_uid"])
assert option["label"] == "Spaces"
def test_unknown_poll_waits_but_an_unknown_option_is_final(client, admin, origin):
setup(client, admin, origin)
body = sync_ok(client, admin, "poll_votes", origin, [poll_vote_record("pv1", poll="ghost"), poll_vote_record("pv2", option="zzz")])
assert (body["results"][0]["status"], body["results"][0]["reason"]) == ("deferred", "unknown_poll")
assert (body["results"][1]["status"], body["results"][1]["reason"]) == ("rejected", "unknown_option")
def test_one_ballot_per_voter_even_with_a_second_origin_id(client, admin, origin):
poll = setup(client, admin, origin)
sync_ok(client, admin, "poll_votes", origin, [poll_vote_record("pv1", option="a")])
sync_ok(client, admin, "poll_votes", origin, [poll_vote_record("pv7", option="b")])
assert get_table("poll_votes").count(poll_uid=poll) == 1
def test_clearing_a_ballot(client, admin, origin):
poll = setup(client, admin, origin)
sync_ok(client, admin, "poll_votes", origin, [poll_vote_record("pv1")])
sync_ok(client, admin, "poll_votes", origin, [poll_vote_record("pv1", updated_at="2025-02-01T00:00:00Z", deleted=True)])
assert get_table("poll_votes").find_one(poll_uid=poll)["deleted_by"] == "sync"
+75
View File
@@ -0,0 +1,75 @@
# retoor <retoor@molodetz.nl>
from devplacepy.database import get_table
from tests.api.sync.conftest import poll_record, seed_world, sync_ok
def options_of(poll_uid):
return {row["label"]: row for row in get_table("poll_options").find(poll_uid=poll_uid)}
def test_a_poll_is_created_with_its_options(client, admin, origin):
seed_world(client, admin, origin)
result = sync_ok(client, admin, "polls", origin, [poll_record()])["results"][0]
assert result["status"] == "created"
poll = get_table("polls").find_one(uid=result["local_uid"])
assert poll["question"] == "Tabs or spaces?" and poll["deleted_at"] is None
assert sorted(options_of(poll["uid"])) == ["Spaces", "Tabs"]
assert sorted(row["position"] for row in options_of(poll["uid"]).values()) == [0, 1]
def test_option_validation(client, admin, origin):
seed_world(client, admin, origin)
one = poll_record(options=[{"origin_uid": "a", "label": "Only"}])
dup = poll_record(options=[{"origin_uid": "a", "label": "X"}, {"origin_uid": "a", "label": "Y"}])
many = poll_record(options=[{"origin_uid": str(index), "label": f"o{index}"} for index in range(7)])
body = sync_ok(client, admin, "polls", origin, [one, dup, many])
assert [item["status"] for item in body["results"]] == ["rejected"] * 3
def test_edit_adds_renames_and_removes_options(client, admin, origin):
seed_world(client, admin, origin)
created = sync_ok(client, admin, "polls", origin, [poll_record()])["results"][0]
edited = poll_record(
question="Tabs, spaces or both?",
options=[{"origin_uid": "a", "label": "Tabs!"}, {"origin_uid": "c", "label": "Both"}],
updated_at="2025-02-01T00:00:00Z",
)
assert sync_ok(client, admin, "polls", origin, [edited])["results"][0]["status"] == "updated"
rows = list(get_table("poll_options").find(poll_uid=created["local_uid"]))
live = sorted(row["label"] for row in rows if not row["deleted_at"])
assert live == ["Both", "Tabs!"]
assert [row["label"] for row in rows if row["deleted_at"]] == ["Spaces"]
assert get_table("polls").find_one(uid=created["local_uid"])["question"] == "Tabs, spaces or both?"
def test_one_poll_per_post(client, admin, origin):
seed_world(client, admin, origin)
sync_ok(client, admin, "polls", origin, [poll_record("po1")])
second = sync_ok(client, admin, "polls", origin, [poll_record("po2")])["results"][0]
assert second["status"] == "rejected" and "already has a poll" in second["errors"][0]
assert get_table("sync_links").count(origin=origin, kind="polls") == 1
def test_a_tombstone_removes_poll_options_and_votes_together_and_restores(client, admin, origin):
seed_world(client, admin, origin)
created = sync_ok(client, admin, "polls", origin, [poll_record()])["results"][0]
from tests.api.sync.conftest import poll_vote_record
sync_ok(client, admin, "poll_votes", origin, [poll_vote_record()])
gone = poll_record(updated_at="2025-02-01T00:00:00Z", deleted=True)
sync_ok(client, admin, "polls", origin, [gone])
uid = created["local_uid"]
assert get_table("polls").find_one(uid=uid)["deleted_at"]
assert all(row["deleted_at"] for row in get_table("poll_options").find(poll_uid=uid))
assert all(row["deleted_at"] for row in get_table("poll_votes").find(poll_uid=uid))
back = poll_record(updated_at="2025-03-01T00:00:00Z")
sync_ok(client, admin, "polls", origin, [back])
assert get_table("polls").find_one(uid=uid)["deleted_at"] is None
assert all(row["deleted_at"] is None for row in get_table("poll_options").find(poll_uid=uid))
def test_an_unknown_post_waits(client, admin, origin):
seed_world(client, admin, origin)
result = sync_ok(client, admin, "polls", origin, [poll_record(post="nope")])["results"][0]
assert result["status"] == "deferred" and result["reason"] == "unknown_post"
+4 -2
View File
@@ -9,11 +9,13 @@ def seed_author(client, admin, origin, uid="u1"):
return body["results"][0]["local_uid"]
def test_unknown_author_is_rejected_with_reason(client, admin, origin):
def test_unknown_author_is_deferred_with_the_reason_and_what_it_waits_for(client, admin, origin):
body = sync_ok(client, admin, "posts", origin, [post_record(author="missing")])
result = body["results"][0]
assert result["status"] == "rejected" and result["reason"] == "unknown_author"
assert result["status"] == "deferred" and result["reason"] == "unknown_author"
assert (result["needs_kind"], result["needs_uid"]) == ("users", "missing")
assert get_table("sync_links").count(origin=origin) == 0
assert get_table("sync_pending").count(origin=origin) == 1
def test_create_normalizes_and_has_no_side_effects(client, admin, origin):
+64
View File
@@ -0,0 +1,64 @@
# retoor <retoor@molodetz.nl>
from devplacepy.database import get_table
from tests.api.sync.conftest import post_record, project_record, sync_ok, unique, user_record
def seed(client, admin, origin):
sync_ok(client, admin, "users", origin, [user_record("u1", unique("pj"))])
def test_a_project_is_created_public_writable_and_without_side_effects(client, admin, origin):
seed(client, admin, origin)
before = get_table("notifications").count()
body = sync_ok(client, admin, "projects", origin, [project_record("pr1", project_type="game", stars=99)])
result = body["results"][0]
assert result["status"] == "created" and result["ignored"] == ["stars"]
row = get_table("projects").find_one(uid=result["local_uid"])
assert (row["is_private"], row["read_only"], row["stars"], row["project_type"]) == (0, 0, 0, "game")
assert row["slug"].endswith("synced-project") and row["deleted_at"] is None
assert get_table("notifications").count() == before
def test_replay_edit_and_tombstone(client, admin, origin):
seed(client, admin, origin)
created = sync_ok(client, admin, "projects", origin, [project_record("pr1")])["results"][0]
assert sync_ok(client, admin, "projects", origin, [project_record("pr1")])["results"][0]["status"] == "unchanged"
edited = sync_ok(client, admin, "projects", origin, [project_record("pr1", title="Renamed", updated_at="2025-02-01T00:00:00Z")])["results"][0]
assert edited["status"] == "updated"
assert get_table("projects").find_one(uid=created["local_uid"])["title"] == "Renamed"
gone = sync_ok(client, admin, "projects", origin, [project_record("pr1", title="Renamed", updated_at="2025-03-01T00:00:00Z", deleted=True)])["results"][0]
assert gone["status"] == "updated"
row = get_table("projects").find_one(uid=created["local_uid"])
assert row["deleted_at"] and row["deleted_by"] == "sync"
def test_unknown_author_and_bad_input(client, admin, origin):
body = sync_ok(client, admin, "projects", origin, [project_record("pr1", "ghost"), project_record("pr2", title="")])
assert body["results"][0]["status"] == "deferred"
assert body["results"][1]["status"] == "rejected" and body["results"][1]["reason"] == "invalid"
def test_unknown_type_falls_back_and_urls_are_cleaned(client, admin, origin):
seed(client, admin, origin)
result = sync_ok(client, admin, "projects", origin, [project_record("pr1", project_type="quantum", website_url="not a url at all", repo_url="example.org/r")])["results"][0]
row = get_table("projects").find_one(uid=result["local_uid"])
assert row["project_type"] == "software" and row["website_url"] == "" and row["repo_url"].startswith("http")
def test_a_post_links_to_its_project_and_waits_for_it(client, admin, origin):
seed(client, admin, origin)
waiting = sync_ok(client, admin, "posts", origin, [post_record("p1", "u1", project_uid="pr1")])["results"][0]
assert waiting["status"] == "deferred" and waiting["reason"] == "unknown_project"
body = sync_ok(client, admin, "projects", origin, [project_record("pr1")])
assert body["released"] == {"created": 1}
post = get_table("posts").find_one(title=None, content="a synced post with enough content", user_uid=get_table("sync_links").find_one(origin=origin, kind="users", origin_uid="u1")["local_uid"])
project = get_table("sync_links").find_one(origin=origin, kind="projects", origin_uid="pr1")["local_uid"]
assert post["project_uid"] == project
def test_a_post_without_a_project_keeps_the_old_fingerprint(client, admin, origin):
seed(client, admin, origin)
sync_ok(client, admin, "posts", origin, [post_record("p1", "u1")])
again = sync_ok(client, admin, "posts", origin, [post_record("p1", "u1", project_uid=None)])["results"][0]
assert again["status"] == "unchanged"
+33
View File
@@ -0,0 +1,33 @@
# retoor <retoor@molodetz.nl>
from devplacepy.database import get_table
from tests.api.sync.conftest import reaction_record, seed_world, sync_ok
def local(origin, kind, origin_uid):
return get_table("sync_links").find_one(origin=origin, kind=kind, origin_uid=origin_uid)["local_uid"]
def test_reactions_are_created_toggled_and_revived(client, admin, origin):
seed_world(client, admin, origin)
created = sync_ok(client, admin, "reactions", origin, [reaction_record("r1")])["results"][0]
assert created["status"] == "created"
row = get_table("reactions").find_one(uid=created["local_uid"])
assert (row["emoji"], row["target_type"], row["target_uid"]) == ("🔥", "post", local(origin, "posts", "p1"))
sync_ok(client, admin, "reactions", origin, [reaction_record("r1", updated_at="2025-02-01T00:00:00Z", deleted=True)])
assert get_table("reactions").find_one(uid=created["local_uid"])["deleted_by"] == "sync"
sync_ok(client, admin, "reactions", origin, [reaction_record("r1", updated_at="2025-03-01T00:00:00Z")])
assert get_table("reactions").find_one(uid=created["local_uid"])["deleted_at"] is None
def test_a_different_emoji_is_a_different_reaction_and_text_is_refused(client, admin, origin):
seed_world(client, admin, origin)
body = sync_ok(client, admin, "reactions", origin, [reaction_record("r1"), reaction_record("r2", emoji="👍"), reaction_record("r3", emoji="nope")])
assert [item["status"] for item in body["results"]] == ["created", "created", "rejected"]
assert get_table("reactions").count(user_uid=local(origin, "users", "u2"), target_uid=local(origin, "posts", "p1")) == 2
def test_reactions_on_comments_and_projects_wait_for_their_target(client, admin, origin):
seed_world(client, admin, origin)
body = sync_ok(client, admin, "reactions", origin, [reaction_record("r1", target="c1", kind="comment"), reaction_record("r2", target="pr1", kind="project"), reaction_record("r3", target="ghost")])
assert [item["status"] for item in body["results"]] == ["created", "created", "deferred"]
+82
View File
@@ -0,0 +1,82 @@
# retoor <retoor@molodetz.nl>
from devplacepy.database import get_table
from tests.api.sync.conftest import seed_world, sync_ok, vote_record
def local(origin, kind, origin_uid):
return get_table("sync_links").find_one(origin=origin, kind=kind, origin_uid=origin_uid)["local_uid"]
def stars(table, uid):
return get_table(table).find_one(uid=uid)["stars"]
def test_votes_derive_stars_from_synced_voters_only(client, admin, origin):
seed_world(client, admin, origin)
post = local(origin, "posts", "p1")
body = sync_ok(client, admin, "votes", origin, [vote_record("v1", "u2"), vote_record("v2", "u3")])
assert [item["status"] for item in body["results"]] == ["created", "created"]
assert stars("posts", post) == 2
sync_ok(client, admin, "votes", origin, [vote_record("v3", "u1", value=-1)])
assert stars("posts", post) == 1
row = get_table("votes").find_one(uid=local(origin, "votes", "v1"))
assert row["target_type"] == "post" and row["user_uid"] == local(origin, "users", "u2")
def test_a_changed_and_a_cleared_vote_move_the_total(client, admin, origin):
seed_world(client, admin, origin)
post = local(origin, "posts", "p1")
sync_ok(client, admin, "votes", origin, [vote_record("v1", "u2")])
sync_ok(client, admin, "votes", origin, [vote_record("v1", "u2", value=-1, updated_at="2025-02-01T00:00:00Z")])
assert stars("posts", post) == -1
sync_ok(client, admin, "votes", origin, [vote_record("v1", "u2", value=-1, updated_at="2025-03-01T00:00:00Z", deleted=True)])
assert stars("posts", post) == 0
sync_ok(client, admin, "votes", origin, [vote_record("v1", "u2", value=1, updated_at="2025-04-01T00:00:00Z")])
assert stars("posts", post) == 1
assert get_table("votes").count(user_uid=local(origin, "users", "u2"), target_uid=post) == 1
def test_a_second_origin_id_for_the_same_voter_revives_the_same_row(client, admin, origin):
seed_world(client, admin, origin)
post = local(origin, "posts", "p1")
sync_ok(client, admin, "votes", origin, [vote_record("v1", "u2")])
sync_ok(client, admin, "votes", origin, [vote_record("v9", "u2", value=-1)])
assert get_table("votes").count(user_uid=local(origin, "users", "u2"), target_uid=post, target_type="post") == 1
assert stars("posts", post) == -1
def test_votes_on_comments_and_projects(client, admin, origin):
seed_world(client, admin, origin)
comment_votes = sync_ok(client, admin, "votes", origin, [vote_record("v1", "u3", "c1", "comment")])
assert comment_votes["results"][0]["status"] == "created"
project = local(origin, "projects", "pr1")
sync_ok(client, admin, "votes", origin, [vote_record("v2", "u2", "pr1", "project")])
assert stars("projects", project) == 1
def test_value_target_and_voter_validation(client, admin, origin):
seed_world(client, admin, origin)
body = sync_ok(
client, admin, "votes", origin,
[vote_record("v1", value=5), vote_record("v2", kind="gist"), vote_record("v3", voter="ghost"), vote_record("v4", target="ghost")],
)
statuses = [(item["status"], item["reason"]) for item in body["results"]]
assert statuses == [("rejected", "invalid"), ("rejected", "invalid"), ("deferred", "unknown_author"), ("deferred", "unknown_target")]
assert body["results"][3]["needs_kind"] == "posts"
def test_a_locally_deleted_vote_stays_deleted(client, admin, origin):
seed_world(client, admin, origin)
created = sync_ok(client, admin, "votes", origin, [vote_record("v1", "u2")])["results"][0]
get_table("votes").update({"uid": created["local_uid"], "deleted_at": "2025-06-01T00:00:00+00:00", "deleted_by": "some-moderator"}, ["uid"])
again = sync_ok(client, admin, "votes", origin, [vote_record("v1", "u2", value=-1, updated_at="2025-07-01T00:00:00Z")])["results"][0]
assert again["status"] == "unchanged" and again["reason"] == "locally_deleted"
def test_dry_run_writes_nothing(client, admin, origin):
seed_world(client, admin, origin)
body = sync_ok(client, admin, "votes", origin, [vote_record("v1", "u2")], dry_run=True)
assert body["results"][0]["status"] == "created"
assert get_table("votes").count(target_uid=local(origin, "posts", "p1")) == 0
assert stars("posts", local(origin, "posts", "p1")) == 0