sirenamc/stackline
Stackline, a team task board you run from the web or from Claude/ChatGPT. FastAPI + DataK3 + appid + Models + MCP.
A remix template · 0 remixes · Remix sirenamc/stackline
tests/test_doors.py
"""Both doors call the same use cases, so the same rules hold for the web page and for Claude."""
import json
from fastapi.testclient import TestClient
from boards.main import create_app
from conftest import MEMBER, OWNER, STRANGER, VIEWER
MCP_ACCEPT = {"accept": "application/json, text/event-stream"}
def _h(p):
return {"X-Dodil-User": json.dumps({"sub": p.email, "email": p.email})}
def _mcp(c, person, tool, **arguments):
h = {**_h(person), **MCP_ACCEPT}
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=h).status_code == 200
call = {"jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": {"name": tool, "arguments": arguments}}
return c.post("/mcp", json=call, headers=h).json()["result"]
def _data(result):
if "structuredContent" in result:
sc = result["structuredContent"]
return sc.get("result", sc)
return json.loads(result["content"][0]["text"])
def test_web_refuses_without_sign_in_and_viewer_cannot_edit(svc):
s, bid = svc
with TestClient(create_app(s)) as c:
assert c.get("/api/boards").status_code == 401
front = c.get("/", follow_redirects=False)
assert front.status_code == 200 and "/.dodil/auth/login" in front.text
assert "api/boards" not in front.text # the front page carries no board data
assert c.get("/static/paper.css").status_code == 200
assert c.get("/api/boards", headers=_h(VIEWER)).json()[0]["role"] == "viewer"
assert c.post(f"/api/boards/{bid}/cards", json={"title": "t"},
headers=_h(VIEWER)).status_code == 403
assert c.get(f"/api/boards/{bid}", headers=_h(STRANGER)).status_code == 404
r = c.post(f"/api/boards/{bid}/cards", json={"title": "t", "list_name": "Doing"},
headers=_h(MEMBER))
assert r.status_code == 201
n = r.json()["number"]
assert c.post(f"/api/cards/{n}/move", json={"list_name": "Done", "position": 0},
headers=_h(MEMBER)).json()["list"] == "Done"
assert c.get("/", headers=_h(MEMBER)).text.startswith("<!doctype html>")
def test_mcp_needs_sign_in_and_lists_action_tools(svc):
s, _ = svc
with TestClient(create_app(s)) as c:
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"]
h = {**_h(MEMBER), **MCP_ACCEPT}
tools = c.post("/mcp", json={"jsonrpc": "2.0", "id": 3, "method": "tools/list"},
headers=h).json()["result"]["tools"]
names = {t["name"] for t in tools}
assert {"my_plate", "overdue_cards", "add_card", "move_card", "assign_card"} <= names
assert not any(n.startswith(("select", "query", "sql")) for n in names)
def test_the_four_brief_sentences_work_over_mcp(svc):
"""'add a card Send invoice to Doing on the Ops board', 'move card 1 to Done',
'what's on my plate this week?', 'what's overdue?'"""
s, _ = svc
with TestClient(create_app(s)) as c:
added = _data(_mcp(c, MEMBER, "add_card", board="Ops", title="Send invoice",
list_name="Doing", assignee="me", due_date="2026-11-12"))
assert added["list"] == "Doing" and added["number"] == 1
late = _data(_mcp(c, MEMBER, "add_card", board="Ops", title="Old report",
assignee="me", due_date="2026-11-01"))
plate = _data(_mcp(c, MEMBER, "my_plate"))
assert [x["title"] for x in plate["due_soon"]] == ["Send invoice"]
assert [x["number"] for x in _data(_mcp(c, VIEWER, "overdue_cards"))] == [late["number"]]
moved = _data(_mcp(c, MEMBER, "move_card", card="1", list_name="Done"))
assert moved["list"] == "Done"
def test_mcp_keeps_the_same_rules(svc):
s, bid = svc
with TestClient(create_app(s)) as c:
_mcp(c, MEMBER, "add_card", board=bid, title="x")
refused = _mcp(c, VIEWER, "move_card", card="1", list_name="Done")
assert refused.get("isError") and "viewer" in refused["content"][0]["text"]
hidden = _mcp(c, STRANGER, "show_board", board="Ops")
assert hidden.get("isError")
assert _mcp(c, MEMBER, "invite_member", board=bid, email="z@x.io").get("isError")
def _rpc(c, person, method, params=None):
h = {**_h(person), **MCP_ACCEPT}
body = {"jsonrpc": "2.0", "id": 5, "method": method, "params": params or {}}
return c.post("/mcp", json=body, headers=h).json()["result"]
READ_ONLY = {"my_boards", "show_board", "my_plate", "overdue_cards", "find_cards", "card_details",
"board_activity", "what_is_blocking", "suggest_checklist", "similar_cards",
"board_summary", "board_members"}
def test_every_tool_says_what_it_does_to_the_board(svc):
s, _ = svc
with TestClient(create_app(s)) as c:
tools = _rpc(c, MEMBER, "tools/list")["tools"]
hints = {t["name"]: t.get("annotations") or {} for t in tools}
assert all(hints.values()), [n for n, a in hints.items() if not a]
assert {n for n, a in hints.items() if a.get("readOnlyHint")} == READ_ONLY
assert {n for n, a in hints.items() if a.get("destructiveHint")} == {
"delete_card", "remove_member"}
assert all(len(t["description"]) > 20 for t in tools)
def test_prompts_show_up_as_slash_commands(svc):
s, _ = svc
with TestClient(create_app(s)) as c:
names = {p["name"] for p in _rpc(c, MEMBER, "prompts/list")["prompts"]}
assert names == {"standup", "plan_my_week", "unblock"}
got = _rpc(c, MEMBER, "prompts/get", {"name": "standup", "arguments": {"board": "Ops"}})
text = got["messages"][0]["content"]["text"]
assert "board_summary" in text and "'Ops'" in text
def test_set_up_board_over_mcp_then_ask_what_blocks_the_launch(svc):
s, _ = svc
with TestClient(create_app(s)) as c:
board = _data(_mcp(c, MEMBER, "set_up_board", title="Site", deadline="2026-11-20",
steps=[{"title": "Copy", "days_before": 5},
{"title": "Launch", "after": [1]}]))
launch = next(x for lst in board["lists"] for x in lst["cards"] if x["title"] == "Launch")
chain = _data(_mcp(c, MEMBER, "what_is_blocking", card=str(launch["number"])))
assert [x["title"] for x in chain["blocking"]] == ["Copy"]
bad = _mcp(c, MEMBER, "set_up_board", title="Bad", steps=[{"title": "x", "after": [3]}])
assert bad.get("isError") and "earlier steps" in bad["content"][0]["text"]
assert "Bad" not in {b["title"] for b in _data(_mcp(c, MEMBER, "my_boards"))}
def test_a_viewer_is_refused_every_change_over_mcp(svc):
s, bid = svc
with TestClient(create_app(s)) as c:
n = str(_data(_mcp(c, MEMBER, "add_card", board=bid, title="Pay rent"))["number"])
for tool, args in [("add_card", {"board": bid, "title": "x"}),
("assign_card", {"card": n, "assignee": "me"}),
("update_card", {"card": n, "title": "y"}),
("delete_card", {"card": n}),
("add_list", {"board": bid, "name": "Later"}),
("invite_member", {"board": bid, "email": "z@x.io"})]:
r = _mcp(c, VIEWER, tool, **args)
assert r.get("isError"), tool
assert _data(_mcp(c, VIEWER, "card_details", card=n))["title"] == "Pay rent"
def test_a_stranger_finds_nothing_over_mcp(svc):
s, bid = svc
with TestClient(create_app(s)) as c:
n = str(_data(_mcp(c, MEMBER, "add_card", board=bid, title="Send invoice"))["number"])
assert _data(_mcp(c, STRANGER, "my_boards")) == []
assert "no card" in _mcp(c, STRANGER, "card_details", card=n)["content"][0]["text"]
assert _mcp(c, STRANGER, "similar_cards", card=n).get("isError")
assert _data(_mcp(c, STRANGER, "find_cards", text="invoice")) == []
assert _data(_mcp(c, STRANGER, "overdue_cards")) == []
def test_example_and_demo_reset_doors(svc):
s, _ = svc
with TestClient(create_app(s)) as c:
assert c.get("/api/me", headers=_h(STRANGER)).json() == {"email": STRANGER.email,
"demo": False}
assert c.post("/api/demo/reset", headers=_h(OWNER)).status_code == 403
r = c.post("/api/example", headers=_h(STRANGER))
assert r.status_code == 201 and len(r.json()["created_cards"]) == 9