rahulpawar/taskdeck
Lanebook, a team task board (boards, lists, cards, roles, history) you run from the web or from Claude/ChatGPT over MCP. FastAPI + DataK3 + appid + Models.
A remix template · 0 remixes · Remix rahulpawar/taskdeck
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, and "today" is pinned."""
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 Forbidden, Invalid, Person
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) # a Monday
class MemoryBoards:
def __init__(self):
self.boards, self.lists, self.cards, self.events = {}, {}, {}, []
def get(self, board_id):
b = self.boards.get(board_id)
if b is None:
return None
from copy import deepcopy
return deepcopy(b)
def boards_for(self, email):
return [self.get(b.id) for b in self.boards.values()
if b.owner_email == email or email in b.members]
def save_board(self, board):
from copy import deepcopy
self.boards[board.id] = deepcopy(board)
def remove_membership(self, board_id, email):
self.boards[board_id].members.pop(email, None)
def lists_on(self, board_id):
return [l for l in self.lists.values() if l.board_id == board_id]
def save_list(self, lst):
self.lists[lst.id] = lst
def delete_list(self, list_id):
del self.lists[list_id]
def get_card(self, card_id):
from copy import deepcopy
return deepcopy(self.cards.get(card_id))
def cards_on(self, board_ids):
from copy import deepcopy
return [deepcopy(c) for c in self.cards.values() if c.board_id in board_ids]
def save_card(self, card):
from copy import deepcopy
self.cards[card.id] = deepcopy(card)
def delete_card(self, card_id):
del self.cards[card_id]
def next_card_number(self, board_id):
return max([c.number for c in self.cards.values() if c.board_id == board_id] or [0]) + 1
def log(self, board_id, card_id, actor, action):
self.events.append({"board_id": board_id, "card_id": card_id, "actor": actor,
"action": action, "at": f"2026-11-02T10:{len(self.events):02d}:00"})
def history(self, board_id, card_id=None, limit=50):
rows = [e for e in self.events if e["board_id"] == board_id
and (card_id is None or e["card_id"] == card_id)]
return list(reversed(rows))[:limit]
class FakeAI:
def checklist(self, title, description=""):
return ["step one", "step two"]
def summary(self, title, facts):
return f"summary of {title}: " + str(facts.count("#"))
def embed(self, texts):
return [[1.0 if "invoice" in t.lower() else 0.0, 1.0] for t in texts]
@pytest.fixture
def svc():
s = Boards(MemoryBoards(), 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 ----------
def test_viewer_cannot_edit_anything(svc):
s, bid = svc
card = s.add_card(MEMBER, bid, "Send invoice")
for attempt in (lambda: s.add_card(VIEWER, bid, "nope"),
lambda: s.move_card(VIEWER, card["number"], "Done"),
lambda: s.update_card(VIEWER, card["id"], title="x"),
lambda: s.assign_card(VIEWER, card["id"], "me"),
lambda: s.delete_card(VIEWER, card["id"]),
lambda: s.add_list(VIEWER, bid, "Later")):
with pytest.raises(Forbidden):
attempt()
assert s.show_board(VIEWER, bid)["role"] == "viewer" # but can look
def test_only_owner_manages_members(svc):
s, bid = svc
with pytest.raises(Forbidden):
s.add_member(MEMBER, bid, "eve@x.io", "member")
with pytest.raises(Forbidden):
s.remove_member(MEMBER, bid, VIEWER.email)
s.add_member(OWNER, bid, VIEWER.email, "member") # role change
assert {p["email"]: p["role"] for p in s.show_board(OWNER, bid)["people"]}[VIEWER.email] == "member"
s.remove_member(OWNER, bid, VIEWER.email)
with pytest.raises(NotFound):
s.show_board(VIEWER, bid)
with pytest.raises(Invalid):
s.remove_member(OWNER, bid, OWNER.email)
def test_nobody_sees_boards_they_are_not_on(svc):
s, bid = svc
card = s.add_card(OWNER, bid, "Secret")
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) == []
assert s.overdue(STRANGER) == [] and s.search_cards(STRANGER, "secret") == []
# ---------- boards, lists, cards ----------
def test_cards_have_numbers_and_move_by_name(svc):
s, bid = svc
a = s.add_card(MEMBER, bid, "Send invoice", "Doing")
b = s.add_card(MEMBER, bid, "Pay rent")
assert (a["number"], b["number"]) == (1, 2) and a["list"] == "Doing"
moved = s.move_card(MEMBER, "card 2", "done", board_ref="ops board")
assert moved["list"] == "Done" and moved["done"]
def test_unknown_list_is_refused(svc):
s, bid = svc
card = s.add_card(OWNER, bid, "x")
with pytest.raises(Invalid):
s.move_card(OWNER, card["id"], "Someday")
def test_card_number_ambiguous_across_boards_asks_which(svc):
s, bid = svc
other = s.create_board(MEMBER, "Home")["id"]
s.add_card(MEMBER, bid, "a")
s.add_card(MEMBER, other, "b")
with pytest.raises(Invalid):
s.move_card(MEMBER, 1, "Done")
assert s.move_card(MEMBER, 1, "Done", board_ref="Home")["title"] == "b"
def test_assignee_must_be_on_board_and_not_viewer(svc):
s, bid = svc
c = s.add_card(OWNER, bid, "x")
with pytest.raises(Invalid):
s.assign_card(OWNER, c["id"], STRANGER.email)
with pytest.raises(Invalid):
s.assign_card(OWNER, c["id"], VIEWER.email)
assert s.assign_card(MEMBER, c["id"], "me")["assignee"] == MEMBER.email
def test_new_list_goes_before_done_and_lists_with_cards_cannot_be_deleted(svc):
s, bid = svc
s.add_list(OWNER, bid, "Review")
names = [l["name"] for l in s.show_board(OWNER, bid)["lists"]]
assert names == ["To do", "Doing", "Review", "Done"]
s.add_card(OWNER, bid, "x", "Review")
with pytest.raises(Invalid):
s.delete_list(OWNER, bid, "Review")
# ---------- history ----------
def test_every_card_change_is_recorded_with_who_and_what(svc):
s, bid = svc
c = s.add_card(MEMBER, bid, "Send invoice")
s.update_card(MEMBER, c["number"], due_date="2026-11-04", labels=["finance"])
s.assign_card(OWNER, c["number"], MEMBER.email)
s.move_card(MEMBER, c["number"], "Doing")
hist = s.show_card(OWNER, c["id"])["history"]
actions = [h["action"] for h in hist]
assert any("due date: none -> 2026-11-04" in a for a in actions)
assert any("labels: none -> finance" in a for a in actions)
assert any("assignee: none -> bo@x.io" in a and h["actor"] == OWNER.email
for a, h in zip(actions, hist))
assert any("list: To do -> Doing" in a for a in actions)
assert any("added card #1" in a for a in actions)
# ---------- the questions people ask ----------
def test_my_plate_this_week_and_overdue(svc):
s, bid = svc
s.add_card(OWNER, bid, "Late thing", assignee=MEMBER.email, due_date="2026-10-30")
s.add_card(OWNER, bid, "This week", assignee=MEMBER.email, due_date="friday")
s.add_card(OWNER, bid, "Next month", assignee=MEMBER.email, due_date="2026-12-01")
s.add_card(OWNER, bid, "Someone else's", assignee=OWNER.email, due_date="tomorrow")
done = s.add_card(OWNER, bid, "Finished", assignee=MEMBER.email, due_date="2026-10-01")
s.move_card(OWNER, done["id"], "Done")
plate = s.my_plate(MEMBER)
assert [c["title"] for c in plate["overdue"]] == ["Late thing"]
assert [c["title"] for c in plate["due_soon"]] == ["This week"]
assert plate["due_soon"][0]["due_date"] == "2026-11-06"
assert [c["title"] for c in s.overdue(VIEWER)] == ["Late thing"] # done cards never overdue
def test_blockers_are_transitive_and_cannot_loop(svc):
s, bid = svc
launch = s.add_card(OWNER, bid, "Launch")
copy = s.add_card(OWNER, bid, "Write copy")
review = s.add_card(OWNER, bid, "Legal review")
s.set_blocked_by(OWNER, launch["number"], [copy["number"]])
s.set_blocked_by(OWNER, copy["number"], [review["number"]])
r = s.what_is_blocking(MEMBER, launch["number"])
assert [b["number"] for b in r["blockers"]] == [2, 3]
with pytest.raises(Invalid):
s.set_blocked_by(OWNER, review["number"], [launch["number"]])
s.move_card(OWNER, review["id"], "Done")
assert [b["number"] for b in s.what_is_blocking(MEMBER, launch["number"])["blockers"]] == [2]
def test_ai_checklist_suggests_then_applies(svc):
s, bid = svc
c = s.add_card(MEMBER, bid, "Plan offsite")
assert s.suggest_checklist(VIEWER, c["number"])["applied"] is False # suggesting changes nothing
with pytest.raises(Forbidden):
s.suggest_checklist(VIEWER, c["number"], apply=True)
out = s.suggest_checklist(MEMBER, c["number"], apply=True)
assert "[ ] step one" in out["description"]
def test_similar_cards_and_summary(svc):
s, bid = svc
a = s.add_card(MEMBER, bid, "Send invoice to Acme")
s.add_card(MEMBER, bid, "Water the plants")
s.add_card(MEMBER, bid, "Chase unpaid invoice")
sim = s.similar_cards(MEMBER, a["number"])
assert sim[0]["title"] == "Chase unpaid invoice"
assert s.board_summary(VIEWER, "Ops")["summary"].startswith("summary of Ops")
# ---------- both doors ----------
def _h(p):
return {"X-Dodil-User": json.dumps({"sub": p.email, "email": p.email})}
def _mcp(c, who, name, args, id_=2):
h = {**_h(who), "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": id_, "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
assert c.get(f"/api/boards/{bid}", headers=_h(STRANGER)).status_code == 404
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"]
# "add a card 'Send invoice' to Doing on the Ops board"
out = _mcp(c, MEMBER, "add_card", {"board": "Ops", "title": "Send invoice",
"list": "Doing", "due_date": "2026-10-31",
"assignee": "me"})
assert not out.get("isError"), out
# "move card 1 to Done" from the web, then back via MCP
card = next(iter(s.repo.cards.values()))
assert c.post(f"/api/cards/{card.id}/move", json={"list": "Done"},
headers=_h(MEMBER)).json()["list"] == "Done"
out = _mcp(c, MEMBER, "move_card", {"card": "1", "list": "To do"})
assert not out.get("isError"), out
# "what's overdue?" / "what's on my plate this week?"
out = _mcp(c, MEMBER, "overdue", {})
assert "Send invoice" in json.dumps(out)
out = _mcp(c, MEMBER, "my_plate", {})
assert "Send invoice" in json.dumps(out)
# the AI only sees what its person sees; viewers stay read-only over MCP too
assert _mcp(c, STRANGER, "show_board", {"board": "Ops"}).get("isError")
assert _mcp(c, VIEWER, "move_card", {"card": "1", "list": "Done"}).get("isError")
# web PATCH: null clears a field and the change is logged
assert c.patch(f"/api/cards/{card.id}", json={"due_date": None},
headers=_h(MEMBER)).json()["due_date"] is None
assert c.get("/", headers=_h(MEMBER)).status_code == 200