rgboards/rg-boards

Lanes, team task boards you run from the web or from Claude/ChatGPT (MCP). FastAPI + DataK3 + appid + Dodil Models.

A remix template · 0 remixes · Remix rgboards/rg-boards

tests/test_edges.py

"""The rules again, from the outside and at the edges: every write door a viewer could try,
every read a stranger could try, bad input, races, AI outages, and the history of every kind
of change. In memory, no Dodil account needed."""
import json
import threading
from datetime import date, datetime, timezone

import pytest
from fastapi.testclient import TestClient

from boards.application.use_cases import Boards, NotFound
from boards.doors.identity import person_from
from boards.domain.model import Conflict, Invalid, Person, parse_due
from boards.infrastructure.memory import MemoryBoards
from boards.main import create_app
from test_boards import FakeAI, MEMBER, OWNER, STRANGER, TODAY, VIEWER, _h, _mcp


def _data(out: dict):
    """A tool result as data: structuredContent when the server sends it, else the text."""
    assert not out.get("isError"), out
    if "structuredContent" in out:
        return out["structuredContent"]
    return json.loads(out["content"][0]["text"])


@pytest.fixture
def world():
    """A board with two cards, a blocker link and a checklist, plus a client for both doors."""
    s = Boards(MemoryBoards(), FakeAI(), today=lambda: TODAY)
    bid = s.create_board(OWNER, "Ops")["id"]
    s.add_member(OWNER, bid, MEMBER.email, "member")
    s.add_member(OWNER, bid, VIEWER.email, "viewer")
    a = s.add_card(OWNER, bid, "Send invoice", due_date="2026-10-01")
    b = s.add_card(OWNER, bid, "Chase payment", assignee=MEMBER.email)
    s.add_blocker(OWNER, b["id"], a["number"])
    item = s.make_checklist(OWNER, a["id"], items=["draft", "send"])["checklist"][0]
    with TestClient(create_app(s), raise_server_exceptions=False) as client:
        yield s, bid, a, b, item, client


def web_writes(bid, a, b, item):
    return [("patch", f"/api/boards/{bid}", {"title": "x"}),
            ("post", f"/api/boards/{bid}/lists", {"name": "Later"}),
            ("delete", f"/api/boards/{bid}/lists/Doing", None),
            ("post", f"/api/boards/{bid}/members", {"email": "new@x.io"}),
            ("delete", f"/api/boards/{bid}/members/{MEMBER.email}", None),
            ("post", f"/api/boards/{bid}/cards", {"title": "t"}),
            ("patch", f"/api/cards/{a['id']}", {"title": "t2"}),
            ("post", f"/api/cards/{a['id']}/move", {"list_name": "Done"}),
            ("post", f"/api/cards/{a['id']}/assign", {"assignee": "me"}),
            ("post", f"/api/cards/{a['id']}/blockers", {"blocked_by": b["number"]}),
            ("delete", f"/api/cards/{b['id']}/blockers/{a['id']}", None),
            ("post", f"/api/cards/{a['id']}/checklist", {"items": ["q"]}),
            ("post", f"/api/cards/{a['id']}/checklist/{item['id']}", {"done": True}),
            ("post", f"/api/cards/{a['id']}/archive", None)]


def mcp_writes(a, b, item):
    return [("add_card", {"board": "Ops", "title": "x"}),
            ("update_card", {"card": a["id"], "title": "y"}),
            ("assign_card", {"card": a["id"], "assignee": "me"}),
            ("move_card", {"card": a["id"], "list_name": "Done"}),
            ("archive_card", {"card": a["id"]}),
            ("add_list", {"board": "Ops", "name": "Q"}),
            ("remove_list", {"board": "Ops", "name": "Doing"}),
            ("rename_board", {"board": "Ops", "title": "Mine now"}),
            ("add_blocker", {"card": a["id"], "blocked_by": b["id"]}),
            ("remove_blocker", {"card": b["id"], "blocked_by": a["id"]}),
            ("make_checklist", {"card": a["id"], "items": ["z"]}),
            ("tick_checklist_item", {"card": a["id"], "item_id": item["id"]}),
            ("add_member", {"board": "Ops", "email": "q@x.io"}),
            ("remove_member", {"board": "Ops", "email": MEMBER.email})]


def _snapshot(s):
    return (json.dumps({k: vars(v) for k, v in s.repo.cards.items()}, default=str, sort_keys=True),
            json.dumps({k: (v.title, v.lists, {e: r.value for e, r in v.members.items()})
                        for k, v in s.repo.boards.items()}, sort_keys=True),
            len(s.repo.events), sorted(s.repo.links), json.dumps(s.repo.items, sort_keys=True))


# ---------------------------------------------------------------- roles, through both doors
def test_viewer_is_refused_on_every_web_write_and_nothing_changes(world):
    s, bid, a, b, item, c = world
    before = _snapshot(s)
    for method, url, body in web_writes(bid, a, b, item):
        r = getattr(c, method)(url, headers=_h(VIEWER), **({"json": body} if body else {}))
        assert r.status_code == 403, (method, url, r.status_code, r.text)
    assert _snapshot(s) == before


def test_stranger_gets_404_on_every_web_route_and_nothing_changes(world):
    s, bid, a, b, item, c = world
    before = _snapshot(s)
    reads = [("get", f"/api/boards/{bid}"), ("get", f"/api/boards/{bid}/members"),
             ("get", f"/api/boards/{bid}/activity"), ("post", f"/api/boards/{bid}/summary"),
             ("get", f"/api/cards/{a['id']}"), ("get", f"/api/cards/{a['id']}/history"),
             ("get", f"/api/cards/{a['id']}/blocking"), ("get", f"/api/cards/{a['id']}/similar"),
             ("post", f"/api/cards/{a['id']}/checklist/suggest"),
             ("get", f"/api/overdue?board={bid}"), ("get", f"/api/search?q=send&board={bid}"),
             ("get", f"/api/blocked?board={bid}")]
    for method, url in reads:
        assert getattr(c, method)(url, headers=_h(STRANGER)).status_code == 404, url
    for method, url, body in web_writes(bid, a, b, item):
        r = getattr(c, method)(url, headers=_h(STRANGER), **({"json": body} if body else {}))
        assert r.status_code == 404, (method, url, r.status_code)
    for url in ("/api/boards", "/api/my-cards", "/api/overdue", "/api/blocked",
                "/api/search?q=invoice"):
        assert c.get(url, headers=_h(STRANGER)).json() == [], url
    assert _snapshot(s) == before


def test_viewer_and_stranger_are_refused_on_every_mcp_write(world):
    s, bid, a, b, item, c = world
    before = _snapshot(s)
    for name, args in mcp_writes(a, b, item):
        out = _mcp(c, VIEWER, name, args)
        assert out.get("isError"), (name, out)
        text = json.dumps(out)
        assert "viewers can't" in text or "only the owner" in text, (name, text)
        out = _mcp(c, STRANGER, name, args)
        assert out.get("isError") and "no such" in json.dumps(out), (name, out)
    assert _snapshot(s) == before


def test_stranger_mcp_reads_see_nothing(world):
    s, bid, a, b, item, c = world
    for name, args in [("show_board", {"board": "Ops"}), ("card_details", {"card": a["id"]}),
                       ("card_history", {"card": a["id"]}), ("what_blocks", {"card": a["id"]}),
                       ("members", {"board": bid}), ("daily_summary", {"board": "Ops"}),
                       ("board_activity", {"board": "Ops"})]:
        out = _mcp(c, STRANGER, name, args)
        assert out.get("isError") and "no such" in json.dumps(out), name
    for name in ("my_boards", "overdue", "my_cards", "blocked_cards"):
        assert _data(_mcp(c, STRANGER, name, {}))["count"] == 0, name
    assert _data(_mcp(c, STRANGER, "search_cards", {"text": "invoice"})) == \
        {"count": 0, "cards": []}


def test_member_edits_but_does_not_manage_people(world):
    s, bid, a, b, item, c = world
    assert c.patch(f"/api/cards/{a['id']}", json={"title": "Send the invoice"},
                   headers=_h(MEMBER)).status_code == 200
    assert c.post(f"/api/boards/{bid}/members", json={"email": "z@x.io"},
                  headers=_h(MEMBER)).status_code == 403
    r = c.patch(f"/api/boards/{bid}", json={"title": "Mine"}, headers=_h(MEMBER))
    assert r.status_code == 403 and "renames" in r.json()["detail"]


def test_invites_and_role_changes_are_owner_only_and_logged(world):
    s, bid, *_ = world
    s.add_member(OWNER, bid, "New@X.io", "viewer")
    assert {"email": "new@x.io", "role": "viewer"} in s.members(VIEWER, bid)
    assert s.show_board(Person("new@x.io"), bid)["role"] == "viewer"
    s.add_member(OWNER, bid, "new@x.io", "member")
    assert s.add_member(OWNER, bid, "new@x.io", "member")["changed"] is False
    last = s.board_activity(OWNER, bid)[0]
    assert last["action"] == "changed new@x.io from viewer to member"
    assert (last["old"], last["new"]) == ("viewer", "member")
    for bad in ("a@", "not an email", "", "x@y"):
        with pytest.raises(Invalid):
            s.add_member(OWNER, bid, bad, "member")
    with pytest.raises(Invalid):
        s.remove_member(OWNER, bid, OWNER.email)
    s.remove_member(OWNER, bid, "new@x.io")
    with pytest.raises(NotFound):
        s.show_board(Person("new@x.io"), bid)


# ---------------------------------------------------------------- identity
@pytest.mark.parametrize("header", ["[]", '"x"', "null", "1", "{}", '{"email": "  "}',
                                    "not json", '{"email": null}'])
def test_malformed_identity_is_nobody_never_a_crash(world, header):
    *_, c = world
    assert c.get("/api/boards", headers={"X-Dodil-User": header}).status_code == 401
    assert c.post("/mcp", json={}, headers={"X-Dodil-User": header}).status_code == 401


def test_dev_user_is_ignored_when_a_user_pool_is_configured(monkeypatch):
    monkeypatch.setenv("DEV_USER", "dev@x.io")
    monkeypatch.delenv("DODIL_USER_POOL", raising=False)
    assert person_from({}) == Person("dev@x.io")
    monkeypatch.setenv("DODIL_USER_POOL", "acme/lanes-users")
    assert person_from({}) is None


def test_a_card_on_a_board_you_cannot_see_looks_like_no_card(world):
    s, *_ = world
    other = s.create_board(STRANGER, "Private")
    hidden = s.add_card(STRANGER, other["id"], "Salary review")
    for who in (OWNER, VIEWER):
        with pytest.raises(NotFound, match="no such card"):
            s.card_details(who, hidden["id"])
        with pytest.raises(NotFound):
            s.card_details(who, "Salary review")
    assert s.find_similar(OWNER, text="salary") and \
        all(c["title"] != "Salary review" for c in s.find_similar(OWNER, text="salary"))


# ---------------------------------------------------------------- input at the edges
def test_bad_input_is_refused_with_a_useful_message(world):
    s, bid, a, *_ = world
    for kwargs in ({"title": ""}, {"title": "   "}, {"title": "x" * 201},
                   {"title": "ok", "due_date": "2026-13-45"}, {"title": "ok", "due_date": "soon"},
                   {"title": "ok", "labels": [str(i) for i in range(11)]},
                   {"title": "ok", "list_name": "Someday"},
                   {"title": "ok", "assignee": VIEWER.email},
                   {"title": "ok", "description": "d" * 5001}):
        with pytest.raises(Invalid):
            s.add_card(OWNER, bid, **kwargs)
    with pytest.raises(Invalid):
        s.update_card(OWNER, a["id"], title="")
    with pytest.raises(Invalid):
        s.create_board(OWNER, "")
    with pytest.raises(Invalid):
        s.add_list(OWNER, bid, "doing")            # names are case-insensitive
    with pytest.raises(Invalid):
        s.search_cards(OWNER, "   ")
    with pytest.raises(Invalid):
        s.find_similar(OWNER)
    for ref in ("999", "#999", "nope-1", "0"):
        with pytest.raises(NotFound):
            s.card_details(OWNER, ref)
    with pytest.raises(Invalid):
        s.card_details(OWNER, "  ")
    long = s.add_card(OWNER, bid, "x" * 200, description="d" * 5000)
    assert len(long["title"]) == 200


def test_labels_are_clean_whatever_shape_they_arrive_in(world):
    s, bid, *_ = world
    for labels in ("Bug, UI", ["bug,ui"], ["Bug", " ui ", "bug", ""]):
        assert s.add_card(OWNER, bid, "t", labels=labels)["labels"] == ["bug", "ui"]


def test_due_dates_in_words():
    fri = date(2026, 11, 6)                       # TODAY is Monday 2026-11-02
    assert TODAY.weekday() == 0
    cases = {"2026-11-07": date(2026, 11, 7), "2026-11-07T18:00:00Z": date(2026, 11, 7),
             "today": TODAY, "tomorrow": date(2026, 11, 3), "Friday": fri, "fri": fri,
             "next friday": fri, "by friday": fri, "monday": date(2026, 11, 9),
             "in 3 days": date(2026, 11, 5), "+2d": date(2026, 11, 4),
             "next week": date(2026, 11, 9), "in 2 weeks": date(2026, 11, 16), "": None}
    for text, want in cases.items():
        assert parse_due(text, TODAY) == want, text
    for bad in ("someday", "2026-02-30", "31/12/2026", "fr"):
        with pytest.raises(Invalid):
            parse_due(bad, TODAY)


def test_cards_can_be_named_by_title(world):
    s, bid, a, *_ = world
    s.add_card(OWNER, bid, "Launch day announcement")
    s.add_card(OWNER, bid, "Write launch blog post")
    # "what is blocking the launch?": the title that starts with the words wins
    assert s.what_blocks(OWNER, "the launch card")["card"]["title"] == "Launch day announcement"
    assert s.move_card(MEMBER, "send invoice", "Doing")["number"] == a["number"]
    s.add_card(OWNER, bid, "Send invoice reminder")
    assert s.card_details(OWNER, "Send invoice")["id"] == a["id"]      # exact title first
    with pytest.raises(Invalid, match="2 cards match .*#1 Send invoice"):
        s.card_details(OWNER, "invoice")                               # ambiguous: ask


def test_a_list_with_a_slash_can_be_removed(world):
    s, bid, *rest = world
    c = rest[-1]
    s.add_list(OWNER, bid, "Q3/Q4")
    r = c.delete(f"/api/boards/{bid}/lists/Q3%2FQ4", headers=_h(OWNER))
    assert r.status_code == 200 and "Q3/Q4" not in r.json()["lists"]


def test_removing_a_link_that_is_not_there_writes_nothing(world):
    s, bid, a, b, *_ = world
    n = len(s.repo.events)
    with pytest.raises(Invalid):
        s.remove_blocker(OWNER, a["id"], b["number"])          # it's b that is blocked by a
    assert len(s.repo.events) == n
    s.remove_blocker(OWNER, b["id"], a["number"])
    assert s.card_details(OWNER, b["id"])["blocked_by"] == []


# ---------------------------------------------------------------- races
def test_a_move_survives_a_concurrent_edit(world):
    s, bid, a, *_ = world
    real, lost = s.repo.update_card, {"n": 0}

    def flaky(card, expected):                     # someone else saved first, once
        if lost["n"] == 0:
            lost["n"] += 1
            return False
        return real(card, expected)
    s.repo.update_card = flaky
    assert s.move_card(MEMBER, a["id"], "Done")["list_name"] == "Done"
    assert lost["n"] == 1


def test_a_stale_editor_save_is_still_refused(world):
    s, bid, a, *_ = world
    s.update_card(MEMBER, a["id"], title="new", expected_version=a["version"])
    with pytest.raises(Conflict):
        s.update_card(OWNER, a["id"], title="old", expected_version=a["version"])


def test_parallel_adds_get_unique_numbers_and_parallel_moves_all_land(world):
    s, bid, *_ = world
    out, errors = [], []

    def add(i):
        try:
            out.append(s.add_card(MEMBER, bid, f"card {i}")["number"])
        except Exception as e:  # pragma: no cover - the assertion below reports it
            errors.append(e)
    threads = [threading.Thread(target=add, args=(i,)) for i in range(12)]
    [t.start() for t in threads]
    [t.join() for t in threads]
    assert not errors and len(set(out)) == 12
    ids = [c.id for c in s.repo.cards.values() if c.title.startswith("card ")]
    threads = [threading.Thread(target=lambda cid=cid: s.move_card(OWNER, cid, "Doing"))
               for cid in ids]
    [t.start() for t in threads]
    [t.join() for t in threads]
    doing = next(l for l in s.show_board(OWNER, bid)["lists"] if l["name"] == "Doing")
    assert len(doing["cards"]) == 12


# ---------------------------------------------------------------- history
def test_every_kind_of_change_is_in_the_history_with_who_and_when(world):
    s, bid, a, b, item, c = world
    s.update_card(MEMBER, a["id"], title="Send invoice #4471", description="net 30",
                  due_date="2026-11-20", labels=["finance"])
    s.assign_card(MEMBER, a["id"], OWNER.email)
    s.move_card(MEMBER, a["id"], "Doing")
    s.tick_item(MEMBER, a["id"], item["id"], True)
    s.add_blocker(MEMBER, a["id"], s.add_card(MEMBER, bid, "PO number")["number"])
    s.archive_card(OWNER, a["id"])
    hist = s.repo.history(a["id"])
    fields = {h["field"] for h in hist}
    assert {"title", "description", "due_date", "labels", "assignee", "list", "checklist",
            "blocked_by", "archived"} <= fields
    for h in hist:
        assert h["actor"] in (OWNER.email, MEMBER.email) and h["action"]
        assert datetime.fromisoformat(h["at"]).tzinfo is not None
    assert [h["at"] for h in hist] == sorted((h["at"] for h in hist), reverse=True)
    due = next(h for h in hist if h["field"] == "due_date")
    assert (due["old"], due["new"], due["actor"]) == ("2026-10-01", "2026-11-20", MEMBER.email)
    r = c.get(f"/api/cards/{a['id']}/history", headers=_h(VIEWER))
    assert r.status_code == 404                     # archived: gone from the board, kept in DB


def test_history_uses_the_use_case_clock():
    stamps = iter([datetime(2026, 10, 1, 9, tzinfo=timezone.utc),
                   datetime(2026, 10, 2, 9, tzinfo=timezone.utc)])
    s = Boards(MemoryBoards(), None, today=lambda: TODAY, now=lambda: next(stamps))
    bid = s.create_board(OWNER, "B")["id"]
    s.add_card(OWNER, bid, "x")
    assert [e["at"][:10] for e in s.board_activity(OWNER, bid)] == ["2026-10-02", "2026-10-01"]


# ---------------------------------------------------------------- AI outages and extras
class DownAI(FakeAI):
    def checklist(self, *a):
        raise TimeoutError("models down")

    def embed(self, *a):
        raise ConnectionError("models down")

    def summary(self, *a):
        raise RuntimeError("models down")


def test_ai_outage_is_a_clear_503_and_writes_nothing(world):
    s, bid, a, b, item, c = world
    s.assistant = DownAI()
    before = _snapshot(s)
    for method, url in (("post", f"/api/cards/{a['id']}/checklist"),
                        ("post", f"/api/cards/{a['id']}/checklist/suggest"),
                        ("get", f"/api/cards/{a['id']}/similar"),
                        ("post", f"/api/boards/{bid}/summary")):
        kw = {"json": {}} if url.endswith("/checklist") else {}
        r = getattr(c, method)(url, headers=_h(OWNER), **kw)
        assert r.status_code == 503 and "Dodil Models" in r.json()["detail"], (url, r.text)
    out = _mcp(c, OWNER, "make_checklist", {"card": a["id"]})
    assert out.get("isError") and "Dodil Models" in json.dumps(out)
    assert _snapshot(s) == before
    # items given by hand need no AI at all
    assert c.post(f"/api/cards/{a['id']}/checklist", json={"items": "a\nb; c"},
                  headers=_h(OWNER)).status_code == 200
    assert [i["text"] for i in s.repo.checklist(a["id"])][-3:] == ["a", "b", "c"]


def test_blocked_cards_lists_what_waits_on_what(world):
    s, bid, a, b, *rest = world
    c = rest[-1]
    rows = c.get("/api/blocked", headers=_h(VIEWER)).json()
    assert [(r["title"], [x["title"] for x in r["blocked_by"]]) for r in rows] == \
        [("Chase payment", ["Send invoice"])]
    s.move_card(OWNER, a["id"], "Done")
    assert s.blocked_cards(VIEWER) == []


# ---------------------------------------------------------------- the brief's prompts over MCP
def test_the_four_prompts_from_the_brief(world):
    s, bid, a, b, item, c = world
    tools = c.post("/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"},
                   headers={**_h(MEMBER), "accept": "application/json, text/event-stream"}
                   ).json()["result"]["tools"]
    names = {t["name"] for t in tools}
    assert {"my_cards", "add_card", "move_card", "overdue", "what_blocks", "blocked_cards",
            "make_checklist", "find_similar", "daily_summary"} <= names
    assert not any(n.startswith(("select", "insert", "sql", "query", "table")) for n in names)
    ann = {t["name"]: t.get("annotations") or {} for t in tools}
    assert ann["overdue"].get("readOnlyHint") is True
    assert ann["archive_card"].get("destructiveHint") is True
    # "what's on my plate this week?"
    plate = _data(_mcp(c, MEMBER, "my_cards", {"days": 7}))["cards"]
    assert all(card["assignee"] == MEMBER.email for card in plate)
    # "add a card 'Send invoice' to Doing on the Ops board"
    added = _mcp(c, MEMBER, "add_card", {"board": "the Ops board", "title": "Send invoice",
                                          "list_name": "doing", "due_date": "friday"})
    card = _data(added)
    assert card["list_name"] == "Doing" and card["due_date"] == "2026-11-06"
    # "move card <n> to Done"
    moved = _mcp(c, MEMBER, "move_card", {"card": card["number"], "list_name": "Done"})
    assert _data(moved)["list_name"] == "Done"
    # "what's overdue?"
    late = _data(_mcp(c, VIEWER, "overdue", {}))["cards"]
    assert [x["title"] for x in late] == ["Send invoice"] and late[0]["overdue"]


# ---------------------------------------------------------------- demo data and schema
def test_seeded_history_reads_like_a_working_week():
    import sys
    from pathlib import Path
    sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
    from seed_demo import seed
    now = datetime(2026, 11, 2, 12, 0, tzinfo=timezone.utc)
    s = Boards(MemoryBoards(), FakeAI(), today=lambda: TODAY)
    seed(s, "o@x.io", "m@x.io", "v@x.io", today=TODAY, now=now)
    stamps = [datetime.fromisoformat(e["at"]) for e in s.repo.events]
    assert len(set(stamps)) == len(stamps)                       # no two events at one instant
    assert stamps == sorted(stamps) and stamps[-1] < now
    assert (stamps[-1] - stamps[0]).days >= 4                    # spread over days
    assert all(t.weekday() < 5 and 3 <= t.hour <= 13 for t in stamps)   # weekdays, ~9-19 IST
    assert s.now.__name__ == "_utcnow"                           # the real clock is back


def test_schema_sql_and_startup_migrations_describe_the_same_tables():
    import re
    from pathlib import Path
    from boards.infrastructure.migrations import SCHEMA, _columns
    sql = (Path(__file__).resolve().parents[1] / "schema.sql").read_text()
    sql = re.sub(r"--[^\n]*", "", sql)
    def tables(text):
        return {t: sorted(c) for t, c in (_columns(s) for s in text.split(";") if s.strip())}
    assert tables(sql) == tables(SCHEMA)
    for stmt in sql.split(";"):
        if stmt.strip():
            assert "PRIMARY KEY" in stmt and "SERIAL" not in stmt.upper()


def test_timestamps_leave_the_repository_in_utc():
    from boards.infrastructure.repository import _in, _utc
    assert _utc(datetime(2026, 10, 30, 9, 30)) == "2026-10-30T09:30:00+00:00"
    assert _utc(None) == ""
    assert _in(["a", "b"]) == ("%s, %s", ("a", "b"))


# ---------------------------------------------------------------- smaller promises
def test_archived_cards_leave_the_board_but_keep_their_number(world):
    s, bid, a, *_ = world
    s.archive_card(OWNER, a["id"])
    with pytest.raises(NotFound):
        s.card_details(OWNER, a["number"], "Ops")
    assert s.add_card(OWNER, bid, "next")["number"] == 3        # #1 is never reused
    assert all(c["id"] != a["id"] for l in s.show_board(OWNER, bid)["lists"] for c in l["cards"])


def test_clearing_a_due_date_and_leaving_a_board_are_recorded(world):
    s, bid, a, b, *_ = world
    s.update_card(MEMBER, a["id"], due_date="")
    h = s.card_history(OWNER, a["id"])[0]
    assert (h["field"], h["old"], h["new"], h["actor"]) == ("due_date", "2026-10-01", None,
                                                            MEMBER.email)
    s.remove_member(OWNER, bid, MEMBER.email)
    h = s.card_history(OWNER, b["id"])[0]
    assert (h["field"], h["old"], h["new"], h["actor"]) == ("assignee", MEMBER.email, None,
                                                            OWNER.email)


def test_owner_tools_over_mcp(world):
    s, bid, a, b, item, c = world
    out = _mcp(c, OWNER, "rename_board", {"board": "Ops", "title": "Operations"})
    assert _data(out)["title"] == "Operations"
    out = _mcp(c, OWNER, "remove_list", {"board": "Operations", "name": "To do"})
    assert out.get("isError") and "move the" in json.dumps(out)       # it still has cards
    out = _mcp(c, MEMBER, "create_board", {"title": "Side project"})
    side = _data(out)["id"]
    assert _data(_mcp(c, MEMBER, "my_boards", {}))["count"] == 2
    assert _mcp(c, OWNER, "show_board", {"board": side}).get("isError")


def test_wrong_types_on_the_web_are_422_never_500(world):
    s, bid, a, *rest = world
    c = rest[-1]
    for url, body in ((f"/api/boards/{bid}/cards", {"title": 5, "labels": [1, {}]}),
                      (f"/api/cards/{a['id']}", {"version": "abc"}),
                      (f"/api/cards/{a['id']}/move", {}),
                      (f"/api/boards/{bid}/members", {"email": None})):
        method = c.patch if url.endswith(a["id"]) else c.post
        assert method(url, json=body, headers=_h(OWNER)).status_code == 422, url
    assert c.get(f"/api/boards/{bid}/activity?limit=abc", headers=_h(OWNER)).status_code == 422


def test_drag_to_the_top_of_another_list_over_the_web(world):
    s, bid, a, b, item, c = world
    x = s.add_card(OWNER, bid, "X", list_name="Doing")
    r = c.post(f"/api/cards/{a['id']}/move", json={"list_name": "Doing", "before_card": x["id"]},
               headers=_h(MEMBER))
    assert r.status_code == 200
    doing = next(l for l in s.show_board(OWNER, bid)["lists"] if l["name"] == "Doing")
    assert [k["title"] for k in doing["cards"]] == ["Send invoice", "X"]
    assert s.card_history(VIEWER, a["id"])[0]["actor"] == MEMBER.email