forked from retoor/devplacepy
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>
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from collections import Counter
|
||||
from itertools import islice
|
||||
|
||||
import pytest
|
||||
|
||||
from devplacepy.database import get_table, init_db
|
||||
from devplacepy.database.sync import claim_identity
|
||||
from devplacepy.services.sync.batch import BatchRunner
|
||||
from devplacepy.services.sync.handles import HandleBuilder
|
||||
from devplacepy.services.sync.records import SyncBatchForm
|
||||
from devplacepy.services.sync.spec import HANDLES
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
ORIGIN = "handles.example.org"
|
||||
OWN_PERSON = {
|
||||
"abner_page_by_abnercoimbre": "abnercoimbre",
|
||||
"jakearchibald_com_by_jaffathecak": "jaffathecak",
|
||||
"spritely_institute_via_untrusem": "untrusem",
|
||||
"libreboot_org_via_runxiyu": "runxiyu",
|
||||
"lumi_codeberg_page_via_stilic": "stilic",
|
||||
"decomp_dev_via_FedericoSchonborn": "federicoschonborn",
|
||||
"via_rberger_2026-10-15_12_36_38_": "rberger",
|
||||
"via_veqq_2026-10-14_23_47_30_cac": "veqq",
|
||||
"costa_jacobpalm_dk_via_FedericoS": "federicos",
|
||||
"entropicthoughts_com_via_lcapald": "lcapald",
|
||||
"github_com_via_gmem": "gmem",
|
||||
"try_haxy_net_via_krig": "krig",
|
||||
"youtu_be_via_jeffective": "jeffective",
|
||||
}
|
||||
DOMAIN_ONLY = {
|
||||
"costa_jacobpalm_dk": "jacobpalm",
|
||||
"techcommunity_microsoft_com": "microsoft",
|
||||
"gtoal_com": "gtoal",
|
||||
"javidjamae": "javidjamae",
|
||||
"john_smith": "john_smith",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw,expected", sorted((OWN_PERSON | DOMAIN_ONLY).items()))
|
||||
def test_the_person_is_found_behind_the_site_and_the_stamp(raw, expected):
|
||||
assert HandleBuilder.person(raw) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", sorted(OWN_PERSON))
|
||||
def test_no_candidate_keeps_the_site_the_stamp_or_the_relation_word(raw):
|
||||
for candidate in islice(HandleBuilder(ORIGIN, raw).candidates(raw), HANDLES.rerolls):
|
||||
lowered = candidate.lower()
|
||||
assert "_via_" not in lowered and "_by_" not in lowered
|
||||
assert "2026" not in lowered and not lowered.startswith("via_")
|
||||
|
||||
|
||||
def test_every_candidate_is_a_valid_handle_across_the_input_domain():
|
||||
raws = ["", "a", "ab", "x" * 80, "UPPER_case", "ünï-cödé", "__", "-a-", "via", "by_via", "9999", "john.smith", "a_b_c_d_e_f_g_h"]
|
||||
raws += [f"user{index}_by_someone{index}" for index in range(300)]
|
||||
for index, raw in enumerate(raws):
|
||||
seen = list(islice(HandleBuilder(ORIGIN, f"u{index}").candidates(raw), 60))
|
||||
assert seen
|
||||
for candidate in seen:
|
||||
assert HANDLES.min_length <= len(candidate) <= HANDLES.max_length, (raw, candidate)
|
||||
assert HandleBuilder.valid(candidate), (raw, candidate)
|
||||
|
||||
|
||||
def test_the_same_record_always_gets_the_same_candidates():
|
||||
first = list(islice(HandleBuilder(ORIGIN, "u1").candidates("someone_via_other"), 40))
|
||||
second = list(islice(HandleBuilder(ORIGIN, "u1").candidates("someone_via_other"), 40))
|
||||
assert first == second
|
||||
|
||||
|
||||
def test_the_mix_follows_the_spec_weights():
|
||||
counts = Counter()
|
||||
total = 4000
|
||||
for index in range(total):
|
||||
first = next(iter(HandleBuilder(ORIGIN, f"id{index}").candidates("kernelhacker")))
|
||||
counts["plain" if first == "kernelhacker" else "digits" if any(char.isdigit() for char in first) else "word"] += 1
|
||||
plain = counts["plain"] / total * 100
|
||||
assert abs(plain - HANDLES.plain_weight) < 5
|
||||
assert counts["digits"] / total * 100 > 25
|
||||
|
||||
|
||||
def test_candidates_differ_between_records_with_the_same_name():
|
||||
firsts = {next(iter(HandleBuilder(ORIGIN, f"id{index}").candidates("same"))) for index in range(200)}
|
||||
assert len(firsts) > 40
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clean(local_db):
|
||||
init_db()
|
||||
yield
|
||||
for table in ("sync_links", "sync_identities"):
|
||||
get_table(table).delete(origin=ORIGIN)
|
||||
|
||||
|
||||
def push(records, humanize):
|
||||
return BatchRunner("users", humanize=humanize).run(SyncBatchForm(origin=ORIGIN, records=records))
|
||||
|
||||
|
||||
def stored_name(origin_uid):
|
||||
link = get_table("sync_links").find_one(origin=ORIGIN, kind="users", origin_uid=origin_uid)
|
||||
return get_table("users").find_one(uid=link["local_uid"])["username"]
|
||||
|
||||
|
||||
def test_a_new_user_gets_a_realistic_handle_and_an_update_keeps_it(clean):
|
||||
raw = f"abner_page_by_abnercoimbre{generate_uid()[-4:]}"
|
||||
record = {"origin_uid": "h1", "username": raw, "created_at": "2025-01-01T00:00:00Z"}
|
||||
push([record], humanize=True)
|
||||
name = stored_name("h1")
|
||||
assert name != raw and "_by_" not in name and name.startswith("abnercoimbre")
|
||||
push([{**record, "bio": "changed bio", "updated_at": "2025-02-01T00:00:00Z"}], humanize=True)
|
||||
assert stored_name("h1") == name
|
||||
|
||||
|
||||
def test_without_the_flag_the_original_behaviour_is_untouched(clean):
|
||||
raw = f"plain_sender_{generate_uid()[-6:]}"
|
||||
push([{"origin_uid": "h2", "username": raw, "created_at": "2025-01-01T00:00:00Z"}], humanize=False)
|
||||
assert stored_name("h2") == raw
|
||||
|
||||
|
||||
def test_an_alias_clone_name_is_never_decorated(clean):
|
||||
alias = f"mirakou{generate_uid()[-5:]}"
|
||||
claim_identity(ORIGIN, "alice", "alice", alias, "{}", False)
|
||||
push([{"origin_uid": "alice", "username": alias, "created_at": "2025-01-01T00:00:00Z"}], humanize=True)
|
||||
assert stored_name("alice") == alias
|
||||
|
||||
|
||||
def test_two_senders_with_one_person_get_two_distinct_free_handles(clean):
|
||||
base = f"samename{generate_uid()[-5:]}"
|
||||
push(
|
||||
[{"origin_uid": f"s{index}", "username": f"site_{index}_via_{base}", "created_at": "2025-01-01T00:00:00Z"} for index in range(12)],
|
||||
humanize=True,
|
||||
)
|
||||
names = [stored_name(f"s{index}") for index in range(12)]
|
||||
assert len({name.lower() for name in names}) == 12
|
||||
@@ -0,0 +1,99 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import pytest
|
||||
|
||||
from devplacepy.database import get_table, init_db
|
||||
from devplacepy.database.sync import claim_identity, claim_link
|
||||
from devplacepy.services.sync.healing import AliasHealer
|
||||
from devplacepy.services.sync.processors import UserProcessor
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
ORIGIN = "heal.example.org"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean(local_db):
|
||||
init_db()
|
||||
for table in ("sync_identities", "sync_links"):
|
||||
get_table(table).delete(origin=ORIGIN)
|
||||
yield
|
||||
for table in ("sync_identities", "sync_links"):
|
||||
get_table(table).delete(origin=ORIGIN)
|
||||
|
||||
|
||||
def clone(source="alice", alias="Mira"):
|
||||
suffix = generate_uid()[-6:]
|
||||
alias = f"{alias}{suffix}"
|
||||
processor = UserProcessor(ORIGIN)
|
||||
result = processor.process(0, {"origin_uid": source, "username": alias, "created_at": "2026-01-01T00:00:00Z"})
|
||||
claim_identity(ORIGIN, source.capitalize(), source, alias, "style", False)
|
||||
return result.local_uid, alias
|
||||
|
||||
|
||||
def synced_text(origin_uid, table, text, kind):
|
||||
uid = generate_uid()
|
||||
get_table(table).insert(
|
||||
{"uid": uid, "user_uid": "u", "title": None, "content": text, "created_at": "2026-01-01T00:00:00+00:00", "target_uid": "x", "target_type": "post", "deleted_at": None, "deleted_by": None}
|
||||
)
|
||||
claim_link(ORIGIN, kind, origin_uid, uid, "fp", "2026-01-01T00:00:00+00:00")
|
||||
return uid
|
||||
|
||||
|
||||
def test_nothing_changes_when_the_clone_still_has_the_alias():
|
||||
clone()
|
||||
report = AliasHealer(ORIGIN).heal()
|
||||
assert not report.renamed and not report.conflicts and report.rewritten == 0
|
||||
|
||||
|
||||
def test_an_administrator_rename_moves_the_identity_and_the_stored_mentions():
|
||||
uid, alias = clone()
|
||||
post = synced_text("p1", "posts", f"thanks @{alias} and {alias} again, @{alias}x stays", "posts")
|
||||
comment = synced_text("c1", "comments", f"@{alias.lower()} agreed", "comments")
|
||||
other = synced_text("p2", "posts", "unrelated text", "posts")
|
||||
renamed = f"Renamed{generate_uid()[-6:]}"
|
||||
get_table("users").update({"uid": uid, "username": renamed}, ["uid"])
|
||||
report = AliasHealer(ORIGIN).heal()
|
||||
assert report.renamed == {alias: renamed} and report.rewritten == 2
|
||||
row = get_table("sync_identities").find_one(origin=ORIGIN, source_key="alice")
|
||||
assert row["our_name"] == renamed and row["our_key"] == renamed.lower()
|
||||
assert get_table("posts").find_one(uid=post)["content"] == f"thanks @{renamed} and {renamed} again, @{alias}x stays"
|
||||
assert get_table("comments").find_one(uid=comment)["content"] == f"@{renamed} agreed"
|
||||
assert get_table("posts").find_one(uid=other)["content"] == "unrelated text"
|
||||
assert not AliasHealer(ORIGIN).heal().renamed
|
||||
|
||||
|
||||
def test_a_suffixed_clone_name_is_adopted(local_db):
|
||||
uid, alias = clone("bob", "Taken")
|
||||
suffixed = f"{alias}-heal"
|
||||
get_table("users").update({"uid": uid, "username": suffixed}, ["uid"])
|
||||
assert AliasHealer(ORIGIN).heal().renamed == {alias: suffixed}
|
||||
assert get_table("sync_identities").find_one(origin=ORIGIN, source_key="bob")["our_name"] == suffixed
|
||||
|
||||
|
||||
def test_a_name_that_belongs_to_another_identity_is_a_conflict_not_a_steal():
|
||||
first_uid, first_alias = clone("alice", "Una")
|
||||
_, second_alias = clone("carol", "Duo")
|
||||
get_table("users").update({"uid": first_uid, "username": second_alias}, ["uid"])
|
||||
report = AliasHealer(ORIGIN).heal()
|
||||
assert report.conflicts == ["alice"] and not report.renamed
|
||||
assert get_table("sync_identities").find_one(origin=ORIGIN, source_key="alice")["our_name"] == first_alias
|
||||
|
||||
|
||||
def test_a_purged_clone_is_ignored():
|
||||
uid, _ = clone()
|
||||
get_table("users").delete(uid=uid)
|
||||
assert AliasHealer(ORIGIN).heal().renamed == {}
|
||||
|
||||
|
||||
def test_the_rewrite_work_per_run_is_bounded(monkeypatch):
|
||||
from dataclasses import replace
|
||||
|
||||
from devplacepy.services.sync import healing
|
||||
from devplacepy.services.sync.spec import ALIAS
|
||||
|
||||
uid, alias = clone()
|
||||
for index in range(5):
|
||||
synced_text(f"p{index}", "posts", f"hello @{alias}", "posts")
|
||||
monkeypatch.setattr(healing, "ALIAS", replace(ALIAS, heal_rewrites_per_run=2))
|
||||
get_table("users").update({"uid": uid, "username": f"Bounded{generate_uid()[-6:]}"}, ["uid"])
|
||||
assert AliasHealer(ORIGIN).heal().rewritten == 2
|
||||
@@ -83,3 +83,67 @@ def test_envelope_normalizes_origin_and_bounds_batch():
|
||||
SyncBatchForm(origin="other.org", records=[{}] * (LIMITS.max_batch_records + 1))
|
||||
with pytest.raises(ValidationError):
|
||||
SyncBatchForm(origin="bad origin", records=[{}])
|
||||
|
||||
|
||||
def test_new_kinds_have_models_and_a_published_schema():
|
||||
from devplacepy.services.sync.records import RECORD_MODELS
|
||||
from devplacepy.services.sync.spec import KINDS
|
||||
|
||||
assert set(RECORD_MODELS) == set(KINDS)
|
||||
for model in RECORD_MODELS.values():
|
||||
assert model.model_json_schema()["title"]
|
||||
|
||||
|
||||
def test_vote_value_and_target_kind_are_closed_sets():
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from devplacepy.services.sync.records import VoteRecord
|
||||
|
||||
base = {"origin_uid": "v", "voter_uid": "u", "target_kind": "post", "target_uid": "p", "value": 1, "created_at": "2025-01-01T00:00:00Z"}
|
||||
assert VoteRecord.model_validate(base).value == 1
|
||||
for change in ({"value": 0}, {"value": 2}, {"target_kind": "gist"}, {"voter_uid": "bad id"}):
|
||||
with pytest.raises(ValidationError):
|
||||
VoteRecord.model_validate({**base, **change})
|
||||
|
||||
|
||||
def test_attachment_fingerprint_ignores_the_blob_but_not_its_hash():
|
||||
import base64
|
||||
|
||||
from devplacepy.services.sync.records import AttachmentRecord
|
||||
|
||||
def make(data):
|
||||
return AttachmentRecord.model_validate({
|
||||
"origin_uid": "a", "author_uid": "u", "target_kind": "post", "target_uid": "p",
|
||||
"filename": "dir/evil\\name.png", "content_b64": base64.b64encode(data).decode(), "created_at": "2025-01-01T00:00:00Z",
|
||||
})
|
||||
|
||||
first, second = make(b"one"), make(b"two")
|
||||
assert first.filename == "name.png"
|
||||
assert "content_b64" not in first.content_fields() and first.content_fields()["sha256"] != second.content_fields()["sha256"]
|
||||
assert first.content_bytes() == b"one"
|
||||
|
||||
|
||||
def test_batch_byte_cap(monkeypatch):
|
||||
import pytest
|
||||
from dataclasses import replace
|
||||
from pydantic import ValidationError
|
||||
|
||||
from devplacepy.services.sync import records
|
||||
from devplacepy.services.sync.spec import LIMITS
|
||||
|
||||
monkeypatch.setattr(records, "LIMITS", replace(LIMITS, max_batch_bytes=100))
|
||||
with pytest.raises(ValidationError):
|
||||
records.SyncBatchForm(origin="a.b", records=[{"x": "y" * 200}])
|
||||
|
||||
|
||||
def test_ack_form_validates_kinds_and_ids():
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from devplacepy.services.sync.records import SyncAckForm
|
||||
|
||||
assert SyncAckForm(origin="A.B", items='[{"kind": "posts", "origin_uid": "p1"}]').items == [{"kind": "posts", "origin_uid": "p1"}]
|
||||
for items in ([], [{"kind": "gists", "origin_uid": "x"}], [{"kind": "posts", "origin_uid": ""}], "not json"):
|
||||
with pytest.raises(ValidationError):
|
||||
SyncAckForm(origin="a.b", items=items)
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from devplacepy.database import get_table, init_db
|
||||
from devplacepy.database.sync import claim_link, load_candidates
|
||||
from devplacepy.services.sync.options import SyncOptions
|
||||
from devplacepy.services.sync.selection import Selector
|
||||
from devplacepy.services.sync.spec import LIMITS, SKIP_MIN_COMMENTS
|
||||
|
||||
ORIGIN = "selection.example.org"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def world(local_db):
|
||||
init_db()
|
||||
get_table("sync_candidates").delete()
|
||||
get_table("sync_links").delete(origin=ORIGIN)
|
||||
yield
|
||||
get_table("sync_candidates").delete()
|
||||
get_table("sync_links").delete(origin=ORIGIN)
|
||||
|
||||
|
||||
def options(**extra):
|
||||
return SyncOptions(min_comments=2, **extra)
|
||||
|
||||
|
||||
def user(uid):
|
||||
return {"origin_uid": uid, "username": f"user{uid}", "created_at": "2026-01-01T00:00:00Z"}
|
||||
|
||||
|
||||
def post(uid, author="a"):
|
||||
return {"origin_uid": uid, "author_uid": author, "content": f"post body {uid} long enough", "created_at": "2026-01-02T00:00:00Z"}
|
||||
|
||||
|
||||
def comment(uid, on, author="b", parent=None):
|
||||
record = {"origin_uid": uid, "post_uid": on, "author_uid": author, "content": f"comment {uid}", "created_at": "2026-01-03T00:00:00Z"}
|
||||
if parent:
|
||||
record["parent_uid"] = parent
|
||||
return record
|
||||
|
||||
|
||||
def run(records, persist=True, **extra):
|
||||
selector = Selector(ORIGIN, options(**extra), persist=persist)
|
||||
return selector, *selector.select(records)
|
||||
|
||||
|
||||
def test_a_post_below_the_minimum_is_parked_with_its_comments_and_authors(world):
|
||||
selector, kept, skipped = run({"users": [user("a"), user("b")], "posts": [post("p1")], "comments": [comment("c1", "p1")]})
|
||||
assert kept["posts"] == [] and kept["comments"] == [] and skipped[SKIP_MIN_COMMENTS] == 1
|
||||
assert selector.parked == 1
|
||||
stored = json.loads(load_candidates(ORIGIN)[0]["record"])
|
||||
assert stored["post"]["origin_uid"] == "p1" and [item["origin_uid"] for item in stored["comments"]] == ["c1"]
|
||||
assert {item["origin_uid"] for item in stored["users"]} == {"a", "b"}
|
||||
|
||||
|
||||
def test_comments_arriving_in_a_later_run_complete_the_post(world):
|
||||
run({"users": [user("a"), user("b")], "posts": [post("p1")], "comments": [comment("c1", "p1")]})
|
||||
selector, kept, skipped = run({"users": [user("b")], "posts": [], "comments": [comment("c2", "p1")]})
|
||||
assert selector.revived == 1
|
||||
assert [item["origin_uid"] for item in kept["posts"]] == ["p1"]
|
||||
assert [item["origin_uid"] for item in kept["comments"]] == ["c1", "c2"]
|
||||
assert {item["origin_uid"] for item in kept["users"]} == {"a", "b"}
|
||||
assert not skipped and load_candidates(ORIGIN) == []
|
||||
|
||||
|
||||
def test_a_repeated_comment_is_counted_once(world):
|
||||
run({"users": [user("a"), user("b")], "posts": [post("p1")], "comments": [comment("c1", "p1")]})
|
||||
selector, kept, skipped = run({"users": [], "posts": [], "comments": [comment("c1", "p1")]})
|
||||
assert kept["posts"] == [] and skipped[SKIP_MIN_COMMENTS] == 1
|
||||
assert len(load_candidates(ORIGIN)) == 1
|
||||
|
||||
|
||||
def test_reply_order_is_kept_parent_before_reply(world):
|
||||
run({"users": [user("a"), user("b")], "posts": [post("p1")], "comments": [comment("c1", "p1")]})
|
||||
_, kept, _ = run({"users": [], "posts": [], "comments": [comment("c2", "p1", parent="c1")]})
|
||||
assert [item["origin_uid"] for item in kept["comments"]] == ["c1", "c2"]
|
||||
|
||||
|
||||
def test_a_dry_run_neither_reads_nor_writes_candidates(world):
|
||||
selector, _, skipped = run({"users": [user("a")], "posts": [post("p1")], "comments": []}, persist=False)
|
||||
assert skipped[SKIP_MIN_COMMENTS] == 1 and selector.parked == 0 and load_candidates(ORIGIN) == []
|
||||
|
||||
|
||||
def test_a_known_post_is_never_parked_or_dropped(world):
|
||||
claim_link(ORIGIN, "posts", "p9", "local-9", "x", "2026-01-01T00:00:00+00:00")
|
||||
selector, kept, skipped = run({"users": [user("a")], "posts": [post("p9")], "comments": []})
|
||||
assert [item["origin_uid"] for item in kept["posts"]] == ["p9"] and not skipped and selector.parked == 0
|
||||
|
||||
|
||||
def test_the_candidate_list_is_capped_per_origin(world, monkeypatch):
|
||||
from dataclasses import replace
|
||||
|
||||
from devplacepy.services.sync import selection
|
||||
|
||||
monkeypatch.setattr(selection, "LIMITS", replace(LIMITS, candidate_max_per_origin=2))
|
||||
selector, _, _ = run({"users": [user("a")], "posts": [post("p1"), post("p2"), post("p3")], "comments": []})
|
||||
assert selector.parked == 2 and len(load_candidates(ORIGIN)) == 2
|
||||
|
||||
|
||||
def test_an_oversize_candidate_is_dropped_not_stored(world, monkeypatch):
|
||||
from dataclasses import replace
|
||||
|
||||
from devplacepy.services.sync import selection
|
||||
|
||||
monkeypatch.setattr(selection, "LIMITS", replace(LIMITS, candidate_max_bytes=50))
|
||||
selector, _, skipped = run({"users": [user("a")], "posts": [post("p1")], "comments": []})
|
||||
assert skipped[SKIP_MIN_COMMENTS] == 1 and selector.parked == 0
|
||||
|
||||
|
||||
def test_expired_candidates_are_not_revived(world):
|
||||
run({"users": [user("a")], "posts": [post("p1")], "comments": []})
|
||||
get_table("sync_candidates").update({"uid": load_candidates(ORIGIN)[0]["uid"], "expires_at": "2000-01-01T00:00:00+00:00"}, ["uid"])
|
||||
selector, _, _ = run({"users": [], "posts": [], "comments": []})
|
||||
assert selector.revived == 0 and load_candidates(ORIGIN) == []
|
||||
|
||||
|
||||
def test_other_filters_stay_final_and_clear_the_candidate(world):
|
||||
run({"users": [user("a")], "posts": [post("p1")], "comments": []})
|
||||
selector, kept, skipped = run({"users": [user("a")], "posts": [], "comments": []}, topics=("devlog",))
|
||||
from devplacepy.services.sync.spec import SKIP_TOPIC
|
||||
|
||||
assert kept["posts"] == [] and skipped[SKIP_TOPIC] == 1 and load_candidates(ORIGIN) == []
|
||||
Reference in New Issue
Block a user