Files
devplacepy/tests/unit/services/sync/alias.py
T
retoor 6f80a820da
DevPlace CI / test (push) Failing after 3m13s
Zo doen wijdat in oost
2026-10-16 07:20:44 +02:00

274 lines
12 KiB
Python

# retoor <retoor@molodetz.nl>
import random
from collections import Counter
import pytest
from devplacepy.database import get_table, init_db
from devplacepy.services.sync import ai
from devplacepy.services.sync.aliaser import AliasRefused, Aliaser
from devplacepy.services.sync.identity import IdentityResolver, NameGenerator, fallback_name, source_key
from devplacepy.services.sync.options import SyncOptions
from devplacepy.services.sync.rewriter import MentionRewriter
from devplacepy.services.sync.selection import Selector, bucket, jittered
from devplacepy.services.sync.style import Style, plausible
from devplacepy.services.sync.translator import Translator
from tests.conftest import run_async
ORIGIN = "alias.test"
@pytest.fixture(autouse=True)
def clean(monkeypatch):
init_db()
for table in ("sync_identities", "sync_links"):
get_table(table).delete(origin=ORIGIN)
async def unreachable(system, text, timeout):
return None
monkeypatch.setattr(ai, "complete", unreachable)
yield
for table in ("sync_identities", "sync_links"):
get_table(table).delete(origin=ORIGIN)
def test_rewriter_longest_first_and_single_pass():
rewriter = MentionRewriter({"alice": "bob", "alice_w": "carol", "bob": "dave"})
result = rewriter.rewrite("hi @alice_w and alice, also @bob")
assert result.text == "hi @carol and bob, also @dave"
assert len(result.substitutions) == 3
def test_rewriter_protects_code_urls_and_inline_code():
rewriter = MentionRewriter({"alice": "zed"})
text = "see `alice` and https://x.test/~alice\n```\nalice = 1\n```\nthen alice"
assert rewriter.rewrite(text).text == text.rsplit("alice", 1)[0] + "zed"
assert rewriter.rewrite("```\nalice unterminated").text == "```\nalice unterminated"
def test_rewriter_standalone_floor_and_case_and_boundaries():
rewriter = MentionRewriter({"ann": "x1", "tef": "y2", "markus": "zeta"})
assert rewriter.rewrite("ann do, @ann, @tef, tef, Markus, markusx, a-markus").text == "ann do, @x1, @y2, tef, zeta, markusx, a-markus"
def test_rewriter_mentioned_skips_code():
found = MentionRewriter({}).mentioned("hi @Alice `@code` @ab ```\n@fenced\n``` @alice_2")
assert found == ["Alice", "alice_2"]
def test_style_roundtrip_and_matching():
style = Style.of("Alice_99")
assert Style.load(style.dump()) == style
assert style.matches("Marta_12") and not style.matches("marta_12") and not style.matches("Marta12")
assert not plausible("alice_99x", "Alice_99", None)
assert not plausible("robotuser", "zzzz", None)
assert not plausible("a!", "zzzz", None)
def test_fallback_name_is_deterministic_and_style_matched():
for source in ("alice", "Bob_Smith", "dev99", "UPPER", "kebab-case"):
style = Style.of(source)
assert fallback_name(ORIGIN, source, style, 0) == fallback_name(ORIGIN, source, style, 0)
assert any(plausible(fallback_name(ORIGIN, source, style, n), source, style) for n in range(60))
def test_generator_rejects_bad_ai_then_falls_back(monkeypatch):
replies = iter(['{"username": "alice"}', "garbage", '{"username": "x"}'])
async def scripted(system, text, timeout):
return next(replies)
monkeypatch.setattr(ai, "complete", scripted)
name, by_ai = run_async(NameGenerator().generate(ORIGIN, "alice", Style.of("alice"), []))
assert not by_ai and plausible(name, "alice", Style.of("alice"))
def test_generator_accepts_good_ai(monkeypatch):
async def good(system, text, timeout):
assert "alice" not in text
return '```json\n{"username": "marta"}\n```'
monkeypatch.setattr(ai, "complete", good)
assert run_async(NameGenerator().generate(ORIGIN, "alice", Style.of("alice"), [])) == ("marta", True)
def test_identity_is_permanent_unique_and_bidirectional():
first = IdentityResolver(ORIGIN)
names = [run_async(first.ensure(n)) for n in ("alice", "Alice", "bobby", "carol")]
assert names[0] == names[1]
assert len({name.lower() for name in names[:1] + names[2:]}) == 3
second = IdentityResolver(ORIGIN)
assert run_async(second.ensure("ALICE")) == names[0]
assert second.created == 0
assert second.mapping()["alice"] == names[0]
rewriter = MentionRewriter({value: key for key, value in second.mapping().items()})
assert rewriter.rewrite(f"@{names[0]}").text == "@alice"
def test_identity_cap_and_invalid_names_fail_closed():
resolver = IdentityResolver(ORIGIN, cap=1)
assert run_async(resolver.ensure("alice"))
assert run_async(resolver.ensure("bobby")) is None
assert run_async(resolver.ensure("not valid!")) is None
assert source_key("x" * 100) is None
def test_dry_run_resolver_persists_nothing():
resolver = IdentityResolver(ORIGIN, persist=False)
assert run_async(resolver.ensure("alice"))
assert get_table("sync_identities").count(origin=ORIGIN) == 0
def test_samples_are_capped():
resolver = IdentityResolver(ORIGIN)
run_async(resolver.ensure("alice"))
for n in range(6):
resolver.remember("alice", f"sample number {n}")
assert len(resolver.samples("alice")) == 3
assert len(IdentityResolver(ORIGIN).samples("alice")) == 3
def records():
users = [
{"origin_uid": "1", "username": "alice", "bio": "secret bio", "website": "https://alice.test", "created_at": "2025-01-01T00:00:00Z"},
{"origin_uid": "2", "username": "bobby", "created_at": "2025-01-02T00:00:00Z"},
]
posts = [
{"origin_uid": "p1", "author_uid": "1", "title": "Hello from alice", "content": "ping @bobby and @ghostly please", "created_at": "2025-02-01T00:00:00Z", "topic": "devlog"},
{"origin_uid": "p2", "author_uid": "9", "title": "orphan", "content": "no known author here", "created_at": "2025-02-02T00:00:00Z"},
]
comments = [
{"origin_uid": "c1", "author_uid": "2", "post_uid": "p1", "content": "thanks alice", "created_at": "2025-02-03T00:00:00Z"},
{"origin_uid": "c2", "author_uid": "2", "post_uid": "p1", "parent_uid": "cX", "content": "reply to missing", "created_at": "2025-02-03T00:00:00Z"},
]
return {"users": users, "posts": posts, "comments": comments}
def leaked(outcome, names=("alice", "bobby")):
blob = " ".join(
str(item.get(name) or "")
for kind in ("users", "posts", "comments")
for item in outcome.records[kind]
for name in ("username", "title", "content", "bio", "website")
).lower()
return [name for name in names if name in blob]
def test_aliaser_rewrites_everything_and_fails_closed():
outcome = run_async(Aliaser(ORIGIN, SyncOptions(alias=True)).apply(records()))
assert leaked(outcome) == []
assert len(outcome.records["posts"]) == 1
assert outcome.skipped["no_author"] == 1
assert [c["origin_uid"] for c in outcome.records["comments"]] == ["c1"]
assert outcome.skipped["parent_skipped"] == 1
users = outcome.records["users"]
assert len(users) == 3
assert all(set(user) == {"origin_uid", "username", "created_at", "deleted"} for user in users)
assert {user["origin_uid"] for user in users} == {"alice", "bobby", "ghostly"}
assert outcome.records["posts"][0]["author_uid"] == "alice"
assert outcome.substitutions >= 4
def test_aliaser_is_stable_across_runs():
first = run_async(Aliaser(ORIGIN, SyncOptions(alias=True)).apply(records()))
second = run_async(Aliaser(ORIGIN, SyncOptions(alias=True)).apply(records()))
assert first.records == second.records
assert second.identities_created == 0
def test_aliaser_refuses_origin_with_original_links():
get_table("sync_links").insert({"uid": "l1", "origin": ORIGIN, "kind": "users", "origin_uid": "1", "local_uid": "x", "fingerprint": "f", "origin_updated_at": "", "first_synced_at": "", "synced_at": ""})
with pytest.raises(AliasRefused):
run_async(Aliaser(ORIGIN, SyncOptions(alias=True)).apply(records()))
def test_aliaser_cap_skips_item_as_transient(monkeypatch):
resolver = IdentityResolver(ORIGIN, cap=1)
outcome = run_async(Aliaser(ORIGIN, SyncOptions(alias=True), resolver=resolver).apply(records()))
assert outcome.transient() >= 1
assert leaked(outcome) == []
class FakeTranslator(Translator):
def __init__(self, reply):
super().__init__("nl")
self.reply = reply
async def translate(self, text, samples=None):
return self.reply(text) if self.reply else None
def test_translation_failure_skips_item_and_holds():
translator = FakeTranslator(None)
outcome = run_async(Aliaser(ORIGIN, SyncOptions(alias=True, translate_to="nl"), language="en", translator=translator).apply(records()))
assert outcome.records["posts"] == [] and outcome.skipped["translation_failed"] == 1
assert outcome.transient() >= 1
def test_translation_output_is_rewritten_again():
translator = FakeTranslator(lambda text: text + " (met @alice)")
outcome = run_async(Aliaser(ORIGIN, SyncOptions(alias=True, translate_to="nl"), language="en", translator=translator).apply(records()))
assert leaked(outcome) == []
assert outcome.translated >= 1
def test_translation_skipped_when_language_matches():
assert not Translator("en").needed("en-US")
assert Translator("en").needed("") and Translator("nl").needed("en")
def test_options_validation_and_roundtrip():
options = SyncOptions.build(alias=True, translate_to="NL", sample_percent=40, min_comments=2, topics="devlog, rant", jitter_percent=10)
assert SyncOptions.from_row({"sync_options": options.to_dict()}) == options
assert not SyncOptions().active()
for bad in ({"sample_percent": 0}, {"jitter_percent": 99}, {"topics": "nonsense"}, {"translate_to": "x y"}, {"min_comments": -1}):
with pytest.raises(ValueError):
SyncOptions.build(**bad)
assert options.merged(alias=False).alias is False and options.merged(alias=None).alias is True
def test_selector_deterministic_sampling_and_filters():
posts = [{"origin_uid": f"p{n}", "author_uid": "1", "topic": "devlog"} for n in range(400)]
comments = [{"origin_uid": "c", "author_uid": "1", "post_uid": "p0"}]
data = {"users": [], "posts": posts, "comments": comments}
options = SyncOptions(sample_percent=50)
kept, skipped = Selector(ORIGIN, options).select(data)
again, _ = Selector(ORIGIN, options).select(data)
assert kept["posts"] == again["posts"]
assert 150 < len(kept["posts"]) < 250
assert skipped["sampled_out"] == 400 - len(kept["posts"])
assert bucket(ORIGIN, "p0") == bucket(ORIGIN, "p0")
kept, skipped = Selector(ORIGIN, SyncOptions(topics=("rant",))).select(data)
assert kept["posts"] == [] and kept["comments"] == [] and skipped["topic_not_allowed"] == 400
kept, skipped = Selector(ORIGIN, SyncOptions(min_comments=1)).select(data)
assert [post["origin_uid"] for post in kept["posts"]] == ["p0"]
untouched, none = Selector(ORIGIN, SyncOptions()).select(data)
assert untouched is data and not none
def test_selector_never_drops_already_synced_posts():
get_table("sync_links").insert({"uid": "l2", "origin": ORIGIN, "kind": "posts", "origin_uid": "p0", "local_uid": "x", "fingerprint": "f", "origin_updated_at": "", "first_synced_at": "", "synced_at": ""})
data = {"users": [], "posts": [{"origin_uid": "p0", "author_uid": "1", "topic": "devlog"}], "comments": []}
kept, _ = Selector(ORIGIN, SyncOptions(topics=("rant",))).select(data)
assert len(kept["posts"]) == 1
def test_jitter_is_bounded():
rng = random.Random(1)
values = [jittered(1000, 20, rng) for _ in range(500)]
assert min(values) >= 800 and max(values) <= 1200 and len(set(values)) > 50
assert jittered(1000, 0) == 1000
assert Counter() == Counter()
def test_mentions_are_case_insensitive_end_to_end():
rewriter = MentionRewriter({"Alice": "zed"})
assert rewriter.rewrite("@ALICE @alice @AlIcE and ALICE").text == "@zed @zed @zed and zed"
data = records()
data["posts"][0]["content"] = "ping @BOBBY and @Bobby and @bObBy"
outcome = run_async(Aliaser(ORIGIN, SyncOptions(alias=True)).apply(data))
assert leaked(outcome) == []
assert len(outcome.records["users"]) == 2 + 0 or {u["origin_uid"] for u in outcome.records["users"]} >= {"alice", "bobby"}