theaio/theaio-boards
Laneway, team task boards you can run from Claude or ChatGPT. FastAPI + DataK3 + appid + Models + MCP.
A remix template · 0 remixes · Remix theaio/theaio-boards
tests/test_doors.py
"""Both doors over the same use cases: the web routes (status codes) and the MCP tools (errors a
person can read), through the real FastAPI app with the in-memory fakes."""
import functools
import json
import re
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from boards.main import create_app
from conftest import MEMBER, OWNER, STRANGER, VIEWER
REQUIRED_TOOLS = {"my_boards", "show_board", "create_board", "add_list", "add_card", "move_card",
"assign_card", "set_due_date", "label_card", "comment_card", "describe_card",
"my_cards", "list_overdue", "card_history", "invite_member", "set_member_role",
"remove_member", "what_is_blocking", "block_card", "find_similar_cards",
"board_summary", "suggest_checklist"}
def _h(p):
return {"X-Dodil-User": json.dumps({"sub": p.email, "email": p.email})}
@pytest.fixture
def client(svc):
s, bid = svc
with TestClient(create_app(s)) as c:
yield c, s, bid
class Mcp:
def __init__(self, c, person):
self.c, self.h = c, {**_h(person), "accept": "application/json, text/event-stream"}
init = {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "t", "version": "0"}}}
assert c.post("/mcp", json=init, headers=self.h).status_code == 200
def raw(self, method, params=None):
body = {"jsonrpc": "2.0", "id": 2, "method": method, "params": params or {}}
return self.c.post("/mcp", json=body, headers=self.h).json()["result"]
def tools(self):
return {t["name"]: t for t in self.raw("tools/list")["tools"]}
def call(self, name, **args):
"""The tool's result, or ('error', message) when the tool refused."""
out = self.raw("tools/call", {"name": name, "arguments": args})
text = out["content"][0]["text"] if out.get("content") else ""
if out.get("isError"):
return "error", text
if "structuredContent" in out:
sc = out["structuredContent"]
return sc.get("result", sc) if set(sc) == {"result"} else sc
return json.loads(text)
# --- web ------------------------------------------------------------------------------------
def test_web_needs_a_person(client):
c, _, _ = client
assert c.get("/api/boards").status_code == 401
assert c.get("/", follow_redirects=False).status_code == 303
page = c.get("/", headers=_h(OWNER))
assert page.status_code == 200 and "Laneway" in page.text
def test_web_viewer_gets_403_and_stranger_404(client):
c, s, bid = client
card = c.post(f"/api/boards/{bid}/cards", json={"title": "t"}, headers=_h(MEMBER)).json()
cid = card["id"]
assert c.get("/api/boards", headers=_h(VIEWER)).json()[0]["role"] == "viewer"
assert c.post(f"/api/boards/{bid}/cards", json={"title": "x"},
headers=_h(VIEWER)).status_code == 403
assert c.post(f"/api/cards/{cid}/move", json={"list_name": "Done"},
headers=_h(VIEWER)).status_code == 403
assert c.patch(f"/api/cards/{cid}", json={"title": "y"}, headers=_h(VIEWER)).status_code == 403
assert c.post(f"/api/boards/{bid}/lists", json={"name": "Later"},
headers=_h(VIEWER)).status_code == 403
assert c.get(f"/api/cards/{cid}", headers=_h(VIEWER)).status_code == 200
assert c.get(f"/api/boards/{bid}", headers=_h(STRANGER)).status_code == 404
assert c.get(f"/api/cards/{cid}", headers=_h(STRANGER)).status_code == 404
assert c.post(f"/api/cards/{cid}/move", json={"list_name": "Done"},
headers=_h(STRANGER)).status_code == 404
assert c.get("/api/boards", headers=_h(STRANGER)).json() == []
def test_web_only_owner_manages_members(client):
c, s, bid = client
url = f"/api/boards/{bid}/members"
assert c.post(url, json={"email": "eve@x.io"}, headers=_h(MEMBER)).status_code == 403
assert c.post(url, json={"email": "eve@x.io", "role": "viewer"},
headers=_h(OWNER)).status_code == 201
assert c.patch(f"{url}/eve@x.io", json={"role": "member"},
headers=_h(MEMBER)).status_code == 403
assert c.patch(f"{url}/eve@x.io", json={"role": "member"},
headers=_h(OWNER)).status_code == 200
assert c.delete(f"{url}/eve@x.io", headers=_h(MEMBER)).status_code == 403
assert c.delete(f"{url}/eve@x.io", headers=_h(OWNER)).status_code == 200
assert c.post(url, json={"email": "not an email"}, headers=_h(OWNER)).status_code == 422
def test_web_drag_and_drop_move_edit_and_history(client):
c, s, bid = client
h = _h(MEMBER)
for t in ("a", "b"):
c.post(f"/api/boards/{bid}/cards", json={"title": t}, headers=h)
doing = s.show_board(OWNER, bid)["lists"][1]["id"] # the page drops onto a list id
moved = c.post(f"/api/cards/{bid}:2/move", json={"list_name": doing, "position": 1},
headers=h).json()
assert moved["list"] == "Doing"
r = c.patch("/api/cards/2", params={"board": bid}, headers=h,
json={"assignee": MEMBER.email, "due_date": "2026-11-30", "labels": ["x"]})
assert r.status_code == 200 and r.json()["due_date"] == "2026-11-30"
assert c.patch("/api/cards/2", json={"assignee": VIEWER.email},
headers=h).status_code == 422
hist = c.get(f"/api/cards/{bid}:2/history", headers=_h(VIEWER)).json()["history"]
assert [x["field"] for x in hist] == ["created", "list", "assignee", "due_date", "labels"]
detail = c.get(f"/api/cards/{bid}:2", headers=h).json()
assert detail["history"] == hist and detail["role"] == "member"
assert c.get("/api/my-cards?days=30", headers=h).json()["cards"][0]["number"] == 2
# --- MCP ------------------------------------------------------------------------------------
def test_mcp_needs_a_person(client):
c, _, _ = client
r = c.post("/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
assert r.status_code == 401 and "resource_metadata" in r.headers["www-authenticate"]
def test_mcp_tools_are_actions_with_guidance(client):
c, _, _ = client
tools = Mcp(c, MEMBER).tools()
assert REQUIRED_TOOLS <= set(tools)
assert not any(re.search(r"sql|table|query|select", n) for n in tools)
assert "first" in tools["my_boards"]["description"].lower()
assert tools["my_cards"]["annotations"]["readOnlyHint"] is True
def test_mcp_the_example_prompts(client):
c, s, bid = client
ana = Mcp(c, OWNER)
ops = ana.call("create_board", title="Ops")
ana.call("invite_member", board="Ops", email=MEMBER.email)
bo = Mcp(c, MEMBER)
# "add a card 'Send invoice' to Doing on the Ops board"
card = bo.call("add_card", board="Ops", title="Send invoice", list_name="Doing")
assert (card["board"], card["list"], card["number"]) == ("Ops", "Doing", 1)
bo.call("assign_card", card="1", email="me", board="Ops")
bo.call("set_due_date", card="Send invoice", due="+3")
# "what's on my plate this week?"
plate = bo.call("my_cards", within_days=7)
assert [x["title"] for x in plate["cards"]] == ["Send invoice"]
bo.call("label_card", card="1", add=["billing"])
bo.call("comment_card", card="1", text="sent the draft")
# "move card 1 to Done": the number alone is enough, card 1 only exists on Ops for bo
assert bo.call("move_card", card="1", to_list="Done")["list"] == "Done"
hist = bo.call("card_history", card="1")["history"]
assert [x["field"] for x in hist][-3:] == ["labels", "comment", "list"]
# "what's overdue?"
assert bo.call("list_overdue")["cards"] == []
assert ops["id"] in {b["id"] for b in bo.call("my_boards")["boards"]}
def test_mcp_errors_are_readable(client):
c, s, bid = client
s.add_card(OWNER, bid, "Launch day")
cy = Mcp(c, VIEWER)
kind, msg = cy.call("move_card", card="1", to_list="Done")
assert kind == "error" and "viewers" in msg
kind, msg = Mcp(c, STRANGER).call("show_board", board="Launch")
assert kind == "error" and "Not found: no board" in msg and "my_boards" in msg
kind, msg = Mcp(c, MEMBER).call("invite_member", board="Launch", email="eve@x.io")
assert kind == "error" and "only the board's owner" in msg
kind, msg = Mcp(c, OWNER).call("move_card", card="1", to_list="Someday")
assert kind == "error" and "To do, Doing, Done" in msg
def test_mcp_and_web_call_the_same_use_cases(client):
c, s, bid = client
seen = []
def spy(name, fn):
@functools.wraps(fn)
def wrapper(*a, **kw):
seen.append(name)
return fn(*a, **kw)
return wrapper
for name in ("add_card", "move_card", "my_cards", "list_overdue", "block_card",
"what_is_blocking"):
setattr(s, name, spy(name, getattr(s, name)))
m = Mcp(c, MEMBER)
m.call("add_card", board=bid, title="first")
m.call("add_card", board=bid, title="second")
m.call("move_card", card="2", to_list="Done")
m.call("block_card", card="1", blocked_by="2")
m.call("what_is_blocking", card_or_board="1")
m.call("my_cards")
m.call("list_overdue")
c.post(f"/api/boards/{bid}/cards", json={"title": "web"}, headers=_h(MEMBER))
c.get("/api/overdue", headers=_h(MEMBER))
assert seen == ["add_card", "add_card", "move_card", "block_card", "what_is_blocking",
"my_cards", "list_overdue", "add_card", "list_overdue"]
def test_no_borrowed_branding_in_the_app():
app_dir = Path(__file__).resolve().parent.parent / "app"
for f in app_dir.rglob("*"):
if f.is_file() and f.suffix in {".py", ".html", ".txt", ""}:
assert "trello" not in f.read_text(errors="ignore").lower(), f