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