Files
devplacepy/tests/api/sync/export.py
T
retoorandClaude Sonnet 5.5 02ab747cd0
DevPlace CI / test (push) Failing after 44m45s
Code Farm: per-plot auto-replant pause and crop-lock check; community sync and node work
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>
2026-10-17 02:23:43 +02:00

204 lines
9.2 KiB
Python

# 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)