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