flc/crewboard
Crewboard - team task board (boards, lists, cards, roles, history, drag and drop, MCP, AI extras). FastAPI + DataK3 + appid + Models.
A remix template · 1 remixes · Remix flc/crewboard
tests/test_boards.py
"""The board's rules, through the use cases and both doors. No Dodil account needed:
the repository and the AI are in-memory fakes."""
import json
from datetime import date
import pytest
from fastapi.testclient import TestClient
from boards.application.use_cases import Boards, NotFound
from boards.domain.model import Conflict, Forbidden, Invalid, Person, board_key
from boards.infrastructure.memory import MemoryBoards
from boards.main import create_app
OWNER, MEMBER, VIEWER, STRANGER = (Person(e) for e in
("ana@x.io", "bo@x.io", "cy@x.io", "dee@x.io"))
TODAY = date(2026, 11, 2)
class Clock:
"""Events happen one minute apart on TODAY."""
def __init__(self):
self.n = 0
def __call__(self):
self.n += 1
return f"{TODAY.isoformat()}T09:{self.n:02d}:00"
class FakeAI:
def checklist(self, title, description=""):
return ["step one", "step two"]
def summary(self, facts):
return "summary of: " + facts.splitlines()[0]
def embed(self, texts):
# a tiny bag of words: texts sharing words point the same way
vocab = ["invoice", "send", "client", "logo", "design", "launch"]
return [[t.lower().count(w) + 0.01 for w in vocab] for t in texts]
@pytest.fixture
def svc():
s = Boards(MemoryBoards(clock=Clock()), FakeAI(), today=lambda: TODAY)
b = s.create_board(OWNER, "Ops")
s.add_member(OWNER, b["id"], MEMBER.email, "member")
s.add_member(OWNER, b["id"], VIEWER.email, "viewer")
return s, b["id"]
# --- roles and visibility -----------------------------------------------------------------
def test_board_key_and_card_numbers(svc):
s, bid = svc
assert bid == "OPS" and board_key("Website launch") == "WL"
a = s.add_card(MEMBER, bid, "first")
b = s.add_card(MEMBER, bid, "second")
assert (a["id"], b["id"]) == ("OPS-1", "OPS-2")
assert s.create_board(OWNER, "ops")["id"] == "OPS2" # the key is taken: next free one
def test_viewer_cannot_edit_anything(svc):
s, bid = svc
card = s.add_card(OWNER, bid, "x")
for action in (lambda: s.add_card(VIEWER, bid, "nope"),
lambda: s.move_card(VIEWER, card["id"], "Done"),
lambda: s.update_card(VIEWER, card["id"], title="hack"),
lambda: s.assign_card(VIEWER, card["id"], VIEWER.email),
lambda: s.archive_card(VIEWER, card["id"])):
with pytest.raises(Forbidden):
action()
assert s.show_board(VIEWER, bid)["cards"][0]["title"] == "x" # but can read
def test_only_owner_manages_people_and_lists(svc):
s, bid = svc
with pytest.raises(Forbidden):
s.add_member(MEMBER, bid, "eve@x.io", "member")
with pytest.raises(Forbidden):
s.add_list(MEMBER, bid, "Review")
with pytest.raises(Forbidden):
s.remove_member(MEMBER, bid, VIEWER.email)
with pytest.raises(Invalid):
s.add_member(OWNER, bid, "eve@x.io", "owner")
def test_nobody_sees_boards_they_are_not_on(svc):
s, bid = svc
card = s.add_card(OWNER, bid, "secret", assignee=MEMBER.email,
due_date="2026-10-01")
with pytest.raises(NotFound):
s.show_board(STRANGER, bid)
with pytest.raises(NotFound):
s.show_card(STRANGER, card["id"])
with pytest.raises(NotFound):
s.move_card(STRANGER, card["id"], "Done")
assert s.my_boards(STRANGER) == [] and s.overdue(STRANGER) == []
assert s.find_cards(STRANGER, "secret") == []
def test_removed_member_loses_access_and_their_cards(svc):
s, bid = svc
card = s.add_card(OWNER, bid, "task", assignee=MEMBER.email)
s.remove_member(OWNER, bid, MEMBER.email)
with pytest.raises(NotFound):
s.show_board(MEMBER, bid)
assert s.show_card(OWNER, card["id"])["assignee"] is None
def test_viewer_cannot_be_assigned(svc):
s, bid = svc
card = s.add_card(OWNER, bid, "task")
with pytest.raises(Invalid):
s.assign_card(OWNER, card["id"], VIEWER.email)
with pytest.raises(Invalid):
s.assign_card(OWNER, card["id"], STRANGER.email)
assert s.assign_card(MEMBER, card["id"], "me")["assignee"] == MEMBER.email
# --- cards, lists, history ------------------------------------------------------------------
def test_every_change_is_recorded_with_before_and_after(svc):
s, bid = svc
card = s.add_card(MEMBER, bid, "Send invoice")
s.update_card(MEMBER, card["id"], due_date="2026-11-05", labels=["Finance", "finance "])
s.move_card(OWNER, card["id"], "doing") # list names forgive case
history = s.card_history(OWNER, card["id"])
actions = [h["action"] for h in history]
assert actions[0] == "moved from To do to Doing"
edit = next(h for h in history if h["action"] == "edited the card")
assert edit["actor"] == MEMBER.email
assert edit["changes"] == {"due_date": [None, "2026-11-05"], "labels": [[], ["finance"]]}
def test_no_change_writes_nothing(svc):
s, bid = svc
card = s.add_card(MEMBER, bid, "same")
before = len(s.repo.events)
s.update_card(MEMBER, card["id"], title="same")
assert len(s.repo.events) == before
def test_stale_edit_is_refused(svc):
s, bid = svc
card = s.add_card(MEMBER, bid, "draft")
s.update_card(OWNER, card["id"], version=card["version"], title="owner's edit")
with pytest.raises(Conflict):
s.update_card(MEMBER, card["id"], version=card["version"], title="member's edit")
assert s.show_card(OWNER, card["id"])["title"] == "owner's edit"
def test_drag_and_drop_order(svc):
s, bid = svc
a, b, c = (s.add_card(OWNER, bid, t)["id"] for t in "abc")
s.move_card(OWNER, c, "To do", before=a)
assert [x["id"] for x in s.show_board(OWNER, bid)["cards"]] == [c, a, b]
with pytest.raises(Invalid):
s.move_card(OWNER, a, "Someday")
def test_lists_can_change_and_cards_follow(svc):
s, bid = svc
s.add_list(OWNER, bid, "Review")
assert s.show_board(OWNER, bid)["lists"] == ["To do", "Doing", "Review", "Done"]
card = s.add_card(OWNER, bid, "x", list_name="Review")
s.rename_list(OWNER, bid, "Review", "QA")
assert s.show_card(OWNER, card["id"])["list_name"] == "QA"
with pytest.raises(Invalid):
s.remove_list(OWNER, bid, "QA") # not empty
s.move_card(OWNER, card["id"], "Done")
assert s.remove_list(OWNER, bid, "qa")["lists"] == ["To do", "Doing", "Done"]
def test_card_refs_by_number_are_scoped_and_unambiguous(svc):
s, bid = svc
s.add_card(OWNER, bid, "on ops")
assert s.move_card(MEMBER, "card 1", "Done")["id"] == "OPS-1"
other = s.create_board(OWNER, "Web")
s.add_card(OWNER, other["id"], "on web")
with pytest.raises(Invalid):
s.show_card(OWNER, "1") # two boards have card 1
assert s.show_card(MEMBER, "#1")["id"] == "OPS-1" # MEMBER only sees OPS
# --- my plate, overdue, dependencies --------------------------------------------------------
def test_my_cards_and_overdue(svc):
s, bid = svc
late = s.add_card(OWNER, bid, "late", assignee=MEMBER.email, due_date="2026-10-30")
soon = s.add_card(OWNER, bid, "soon", assignee=MEMBER.email, due_date="2026-11-06")
s.add_card(OWNER, bid, "later", assignee=MEMBER.email, due_date="2026-12-20")
done = s.add_card(OWNER, bid, "done", assignee=MEMBER.email, due_date="2026-10-01")
s.move_card(OWNER, done["id"], "Done")
s.add_card(OWNER, bid, "not mine", assignee=OWNER.email, due_date="2026-11-03")
plate = s.my_cards(MEMBER)
assert [c["id"] for c in plate["overdue"]] == [late["id"]]
assert [c["id"] for c in plate["due_soon"]] == [soon["id"]]
assert len(plate["later"]) == 1
assert [o["id"] for o in s.overdue(VIEWER)] == [late["id"]]
assert s.overdue(VIEWER)[0]["days_late"] == 3
assert s.show_card(OWNER, late["id"])["overdue"] is True
def test_dependencies_chain_and_no_loops(svc):
s, bid = svc
launch, site, copy_ = (s.add_card(OWNER, bid, t)["id"] for t in ("Launch", "Site", "Copy"))
s.block_card(MEMBER, launch, site)
s.block_card(MEMBER, site, copy_)
assert [c["id"] for c in s.what_blocks(VIEWER, launch)["waiting_for"]] == [site, copy_]
with pytest.raises(Invalid):
s.block_card(MEMBER, copy_, launch) # would be a loop
with pytest.raises(Invalid):
s.block_card(MEMBER, launch, launch)
s.move_card(OWNER, copy_, "Done")
assert [c["id"] for c in s.what_blocks(OWNER, launch)["waiting_for"]] == [site]
s.unblock_card(OWNER, launch, site)
assert s.what_blocks(OWNER, launch)["blocked"] is False
def test_dependency_must_stay_on_one_board(svc):
s, bid = svc
a = s.add_card(OWNER, bid, "a")["id"]
other = s.create_board(OWNER, "Web")["id"]
b = s.add_card(OWNER, other, "b")["id"]
with pytest.raises(Invalid):
s.block_card(OWNER, a, b)
# --- AI: suggestions only ------------------------------------------------------------------
def test_ai_suggests_and_a_person_applies(svc):
s, bid = svc
card = s.add_card(MEMBER, bid, "Send invoice")
out = s.suggest_checklist(VIEWER, card["id"])
assert out["suggested_checklist"] == ["step one", "step two"]
assert s.show_card(OWNER, card["id"])["description"] == "" # suggesting changed nothing
s.apply_checklist(MEMBER, card["id"], ["step one"])
assert s.show_card(OWNER, card["id"])["description"] == "- [ ] step one"
with pytest.raises(Forbidden):
s.apply_checklist(VIEWER, card["id"], ["x"])
def test_similar_cards_and_summary(svc):
s, bid = svc
a = s.add_card(OWNER, bid, "Send invoice to client")["id"]
s.add_card(OWNER, bid, "Design the logo")
b = s.add_card(OWNER, bid, "Invoice reminder for client")["id"]
assert s.similar_cards(MEMBER, a)["similar"][0]["id"] == b
summary = s.board_summary(VIEWER, bid)
assert summary["summary"].startswith("summary of: Board: Ops")
assert summary["changes_last_24h"] >= 3
# --- the two doors share the rules ----------------------------------------------------------
def _h(p):
return {"X-Dodil-User": json.dumps({"sub": p.email, "email": p.email})}
def _mcp(c, person, name, args, rid=2):
h = {**_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=h).status_code == 200
call = {"jsonrpc": "2.0", "id": rid, "method": "tools/call",
"params": {"name": name, "arguments": args}}
return c.post("/mcp", json=call, headers=h).json()["result"]
def test_web_and_mcp_doors_share_the_rules(svc):
s, bid = svc
with TestClient(create_app(s)) as c:
assert c.get("/api/boards").status_code == 401
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
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"]
out = _mcp(c, MEMBER, "add_card", {"board_id": bid, "title": "Send invoice",
"list_name": "Doing", "assignee": "me",
"due_date": "2026-11-04"})
assert not out.get("isError"), out
out = _mcp(c, MEMBER, "my_cards", {})
plate = out.get("structuredContent") or json.loads(out["content"][0]["text"])
assert plate["due_soon"][0]["title"] == "Send invoice"
out = _mcp(c, VIEWER, "move_card", {"card": "OPS-1", "list_name": "Done"})
assert out.get("isError") and "viewers" in out["content"][0]["text"]
out = _mcp(c, STRANGER, "show_board", {"board_id": bid})
assert out.get("isError")
out = _mcp(c, MEMBER, "move_card", {"card": "1", "list_name": "done"})
assert not out.get("isError"), out
# web: optimistic lock through If-Match
card = c.get("/api/cards/OPS-1", headers=_h(OWNER)).json()
ok = c.patch("/api/cards/OPS-1", json={"title": "v2"},
headers={**_h(OWNER), "If-Match": str(card["version"])})
stale = c.patch("/api/cards/OPS-1", json={"title": "v3"},
headers={**_h(MEMBER), "If-Match": str(card["version"])})
assert ok.status_code == 200 and stale.status_code == 409
assert c.post("/", headers={}).json()["ok"] is True # self-check route
def test_every_mcp_tool_is_an_action_not_a_table():
from boards.doors.mcp import build_mcp
import asyncio
tools = asyncio.run(build_mcp(Boards(MemoryBoards())).list_tools())
names = {t.name for t in tools}
assert {"my_cards", "overdue", "move_card", "assign_card", "add_card", "what_blocks",
"board_summary", "similar_cards"} <= names
assert not any(n.startswith(("select", "insert", "sql", "query", "table")) for n in names)