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