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)